1use std::path::Path;
12use std::sync::{Arc, Mutex};
13
14use rusqlite::{params, Connection};
15use serde::{Deserialize, Serialize};
16
17use crate::backend::obj::{wave_obj_from_json, wave_obj_to_json, WaveObj};
18use crate::registry::Registry;
19
20use super::error::StoreError;
21use super::migrations::{check_schema_compat, run_object_schema, stamp_version, OBJECT_SCHEMA_VERSION};
22
23pub struct WaveStore {
25 conn: Mutex<Connection>,
26 registry: Mutex<Option<Arc<Registry>>>,
31}
32
33impl WaveStore {
34 pub fn open(path: &Path) -> Result<Self, StoreError> {
37 let conn = Connection::open(path)?;
38 Self::configure_and_migrate(conn)
39 }
40
41 #[allow(dead_code)]
43 pub fn open_in_memory() -> Result<Self, StoreError> {
44 let conn = Connection::open_in_memory()?;
45 Self::configure_and_migrate(conn)
46 }
47
48 pub(crate) fn conn(&self) -> &Mutex<Connection> {
53 &self.conn
54 }
55
56 pub fn run_agents_consolidate(
60 &self,
61 data_dir: Option<&Path>,
62 ) -> Result<super::agents_consolidate::ConsolidateStats, StoreError> {
63 let mut conn = self.conn.lock().unwrap();
64 super::agents_consolidate::run_consolidate_migration(&mut conn, data_dir)
65 }
66
67 fn configure_and_migrate(conn: Connection) -> Result<Self, StoreError> {
68 conn.execute_batch(
69 "PRAGMA journal_mode=WAL;
77 PRAGMA busy_timeout=5000;
78 PRAGMA foreign_keys=ON;
79 PRAGMA synchronous=NORMAL;
80 PRAGMA cache_size=-8000;
81 PRAGMA mmap_size=268435456;
82 PRAGMA temp_store=MEMORY;",
83 )?;
84 check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db")?;
91 run_object_schema(&conn)?;
92 stamp_version(&conn, OBJECT_SCHEMA_VERSION)?;
93 Ok(Self {
94 conn: Mutex::new(conn),
95 registry: Mutex::new(None),
96 })
97 }
98
99 pub fn set_registry(&self, registry: Arc<Registry>) {
105 *self.registry.lock().unwrap_or_else(|e| e.into_inner()) = Some(registry);
106 }
107
108 fn registry(&self) -> Option<Arc<Registry>> {
109 self.registry
110 .lock()
111 .unwrap_or_else(|e| e.into_inner())
112 .clone()
113 }
114
115 pub fn shared_agent_registry(&self) -> Option<Arc<Registry>> {
120 self.registry()
121 }
122
123 fn table_name<T: WaveObj>() -> String {
125 format!("db_{}", T::get_otype())
126 }
127
128 pub fn get<T: WaveObj>(&self, oid: &str) -> Result<Option<T>, StoreError> {
130 let conn = self.conn.lock().unwrap();
131 let table = Self::table_name::<T>();
132 let mut stmt =
133 conn.prepare(&format!("SELECT version, data FROM {table} WHERE oid = ?1"))?;
134
135 let result = stmt.query_row(params![oid], |row| {
136 let version: i64 = row.get(0)?;
137 let data: Vec<u8> = row.get(1)?;
138 Ok((version, data))
139 });
140
141 match result {
142 Ok((version, data)) => {
143 let mut obj: T = wave_obj_from_json(&data)?;
144 obj.set_version(version);
145 Ok(Some(obj))
146 }
147 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
148 Err(e) => Err(StoreError::Sqlite(e)),
149 }
150 }
151
152 pub fn must_get<T: WaveObj>(&self, oid: &str) -> Result<T, StoreError> {
154 self.get::<T>(oid)?.ok_or(StoreError::NotFound)
155 }
156
157 pub fn get_raw(&self, otype: &str, oid: &str) -> Result<Option<serde_json::Value>, StoreError> {
160 let conn = self.conn.lock().unwrap();
161 let table = format!("db_{}", otype);
162 let mut stmt =
163 conn.prepare(&format!("SELECT version, data FROM {table} WHERE oid = ?1"))?;
164
165 let result = stmt.query_row(params![oid], |row| {
166 let version: i64 = row.get(0)?;
167 let data: Vec<u8> = row.get(1)?;
168 Ok((version, data))
169 });
170
171 match result {
172 Ok((version, data)) => {
173 let mut val: serde_json::Value = serde_json::from_slice(&data)
174 .map_err(|e| StoreError::Json(e))?;
175 if let Some(obj) = val.as_object_mut() {
176 obj.insert("version".to_string(), serde_json::json!(version));
177 obj.insert("otype".to_string(), serde_json::json!(otype));
178 }
179 Ok(Some(val))
180 }
181 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
182 Err(e) => Err(StoreError::Sqlite(e)),
183 }
184 }
185
186 #[allow(dead_code)]
188 pub fn exists_raw(&self, otype: &str, oid: &str) -> Result<bool, StoreError> {
189 let conn = self.conn.lock().unwrap();
190 let table = format!("db_{}", otype);
191 let count: i64 = conn.query_row(
192 &format!("SELECT COUNT(*) FROM {table} WHERE oid = ?1"),
193 params![oid],
194 |row| row.get(0),
195 )?;
196 Ok(count > 0)
197 }
198
199 pub fn insert<T: WaveObj>(&self, obj: &mut T) -> Result<(), StoreError> {
201 let oid = obj.get_oid().to_string();
202 if oid.is_empty() {
203 return Err(StoreError::EmptyOID);
204 }
205
206 obj.set_version(1);
207 let data = wave_obj_to_json(obj)?;
208
209 let conn = self.conn.lock().unwrap();
210 let table = Self::table_name::<T>();
211 conn.execute(
212 &format!("INSERT INTO {table} (oid, version, data) VALUES (?1, 1, ?2)"),
213 params![oid, data],
214 )?;
215
216 Ok(())
217 }
218
219 pub fn update<T: WaveObj>(&self, obj: &mut T) -> Result<i64, StoreError> {
222 let oid = obj.get_oid().to_string();
223 if oid.is_empty() {
224 return Err(StoreError::EmptyOID);
225 }
226
227 let data = wave_obj_to_json(obj)?;
228
229 let conn = self.conn.lock().unwrap();
230 let table = Self::table_name::<T>();
231
232 let new_version: i64 = conn.query_row(
235 &format!(
236 "UPDATE {table} SET data = ?1, version = version + 1 WHERE oid = ?2 RETURNING version"
237 ),
238 params![data, oid],
239 |row| row.get(0),
240 )?;
241
242 obj.set_version(new_version);
243 Ok(new_version)
244 }
245
246 pub fn update_raw(&self, otype: &str, oid: &str, value: &serde_json::Value) -> Result<i64, StoreError> {
250 if oid.is_empty() {
251 return Err(StoreError::EmptyOID);
252 }
253 let data = serde_json::to_vec(value)?;
254 let conn = self.conn.lock().unwrap();
255 let table = format!("db_{}", otype);
256 let new_version: i64 = conn.query_row(
257 &format!(
258 "UPDATE {table} SET data = ?1, version = version + 1 WHERE oid = ?2 RETURNING version"
259 ),
260 params![data, oid],
261 |row| row.get(0),
262 )?;
263 Ok(new_version)
264 }
265
266 #[allow(dead_code)]
268 pub fn delete<T: WaveObj>(&self, oid: &str) -> Result<(), StoreError> {
269 let conn = self.conn.lock().unwrap();
270 let table = Self::table_name::<T>();
271 conn.execute(
272 &format!("DELETE FROM {table} WHERE oid = ?1"),
273 params![oid],
274 )?;
275 Ok(())
276 }
277
278 #[allow(dead_code)]
281 pub fn delete_by_otype(&self, otype: &str, oid: &str) -> Result<(), StoreError> {
282 if !crate::backend::obj::VALID_OTYPES.contains(&otype) {
283 return Err(StoreError::Other(format!("unknown otype: {otype:?}")));
284 }
285 let conn = self.conn.lock().unwrap();
286 let table = format!("db_{otype}");
287 conn.execute(
288 &format!("DELETE FROM {table} WHERE oid = ?1"),
289 params![oid],
290 )?;
291 Ok(())
292 }
293
294 pub fn get_all<T: WaveObj>(&self) -> Result<Vec<T>, StoreError> {
296 let conn = self.conn.lock().unwrap();
297 let table = Self::table_name::<T>();
298 let mut stmt = conn.prepare(&format!("SELECT oid, version, data FROM {table}"))?;
299 let rows = stmt.query_map([], |row| {
300 let version: i64 = row.get(1)?;
301 let data: Vec<u8> = row.get(2)?;
302 Ok((version, data))
303 })?;
304
305 let mut result = Vec::new();
306 for row in rows {
307 let (version, data) = row?;
308 let mut obj: T = wave_obj_from_json(&data)?;
309 obj.set_version(version);
310 result.push(obj);
311 }
312 Ok(result)
313 }
314
315 #[allow(dead_code)]
317 pub fn count<T: WaveObj>(&self) -> Result<i64, StoreError> {
318 let conn = self.conn.lock().unwrap();
319 let table = Self::table_name::<T>();
320 let count: i64 =
321 conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |row| {
322 row.get(0)
323 })?;
324 Ok(count)
325 }
326
327 pub fn with_tx<F, R>(&self, f: F) -> Result<R, StoreError>
334 where
335 F: FnOnce(&StoreTx) -> Result<R, StoreError>,
336 {
337 let conn = self.conn.lock().unwrap();
338 conn.execute_batch("BEGIN")?;
339 let tx = StoreTx { conn: &conn };
340 match f(&tx) {
341 Ok(result) => {
342 conn.execute_batch("COMMIT")?;
343 Ok(result)
344 }
345 Err(e) => {
346 let _ = conn.execute_batch("ROLLBACK");
347 Err(e)
348 }
349 }
350 }
351}
352
353pub struct StoreTx<'a> {
357 conn: &'a Connection,
358}
359
360impl<'a> StoreTx<'a> {
361 fn table_name<T: WaveObj>() -> String {
362 format!("db_{}", T::get_otype())
363 }
364
365 pub fn get<T: WaveObj>(&self, oid: &str) -> Result<Option<T>, StoreError> {
366 let table = Self::table_name::<T>();
367 let mut stmt =
368 self.conn.prepare(&format!("SELECT version, data FROM {table} WHERE oid = ?1"))?;
369
370 let result = stmt.query_row(params![oid], |row| {
371 let version: i64 = row.get(0)?;
372 let data: Vec<u8> = row.get(1)?;
373 Ok((version, data))
374 });
375
376 match result {
377 Ok((version, data)) => {
378 let mut obj: T = wave_obj_from_json(&data)?;
379 obj.set_version(version);
380 Ok(Some(obj))
381 }
382 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
383 Err(e) => Err(StoreError::Sqlite(e)),
384 }
385 }
386
387 pub fn must_get<T: WaveObj>(&self, oid: &str) -> Result<T, StoreError> {
388 self.get::<T>(oid)?.ok_or(StoreError::NotFound)
389 }
390
391 pub fn insert<T: WaveObj>(&self, obj: &mut T) -> Result<(), StoreError> {
392 let oid = obj.get_oid().to_string();
393 if oid.is_empty() {
394 return Err(StoreError::EmptyOID);
395 }
396
397 obj.set_version(1);
398 let data = wave_obj_to_json(obj)?;
399
400 let table = Self::table_name::<T>();
401 self.conn.execute(
402 &format!("INSERT INTO {table} (oid, version, data) VALUES (?1, 1, ?2)"),
403 params![oid, data],
404 )?;
405
406 Ok(())
407 }
408
409 pub fn update<T: WaveObj>(&self, obj: &mut T) -> Result<i64, StoreError> {
410 let oid = obj.get_oid().to_string();
411 if oid.is_empty() {
412 return Err(StoreError::EmptyOID);
413 }
414
415 let data = wave_obj_to_json(obj)?;
416
417 let table = Self::table_name::<T>();
418 let new_version: i64 = self.conn.query_row(
419 &format!(
420 "UPDATE {table} SET data = ?1, version = version + 1 WHERE oid = ?2 RETURNING version"
421 ),
422 params![data, oid],
423 |row| row.get(0),
424 )?;
425
426 obj.set_version(new_version);
427 Ok(new_version)
428 }
429
430 pub fn get_all<T: WaveObj>(&self) -> Result<Vec<T>, StoreError> {
431 let table = Self::table_name::<T>();
432 let mut stmt = self.conn.prepare(&format!("SELECT oid, version, data FROM {table}"))?;
433 let rows = stmt.query_map([], |row| {
434 let version: i64 = row.get(1)?;
435 let data: Vec<u8> = row.get(2)?;
436 Ok((version, data))
437 })?;
438
439 let mut result = Vec::new();
440 for row in rows {
441 let (version, data) = row?;
442 let mut obj: T = wave_obj_from_json(&data)?;
443 obj.set_version(version);
444 result.push(obj);
445 }
446 Ok(result)
447 }
448
449 #[allow(dead_code)]
450 pub fn delete<T: WaveObj>(&self, oid: &str) -> Result<(), StoreError> {
451 let table = Self::table_name::<T>();
452 self.conn.execute(
453 &format!("DELETE FROM {table} WHERE oid = ?1"),
454 params![oid],
455 )?;
456 Ok(())
457 }
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct AgentDefinition {
467 pub id: String,
468 #[serde(default)]
474 pub slug: String,
475 pub name: String,
476 pub icon: String,
477 pub provider: String,
478 pub description: String,
479 #[serde(default)]
480 pub working_directory: String,
481 #[serde(default)]
482 pub shell: String,
483 #[serde(default)]
484 pub provider_flags: String,
485 #[serde(default)]
486 pub auto_start: i64,
487 #[serde(default)]
488 pub restart_on_crash: i64,
489 #[serde(default)]
490 pub idle_timeout_minutes: i64,
491 pub created_at: i64,
492 #[serde(default = "default_agent_type")]
493 pub agent_type: String,
494 #[serde(default)]
495 pub environment: String,
496 #[serde(default)]
497 pub agent_bus_id: String,
498 #[serde(default)]
499 pub is_seeded: i64,
500 #[serde(default)]
509 pub accounts: String,
510 #[serde(default)]
514 pub parent_id: String,
515 #[serde(default)]
519 pub branch_label: String,
520 #[serde(default)]
524 pub updated_at: i64,
525 #[serde(default)]
536 pub user_hidden: i64,
537}
538
539pub fn derive_slug(name: &str) -> String {
544 let filtered: String = name
545 .to_lowercase()
546 .chars()
547 .map(|c| {
548 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
549 c
550 } else {
551 '-'
552 }
553 })
554 .collect();
555 let collapsed: String = filtered
556 .split('-')
557 .filter(|s| !s.is_empty())
558 .collect::<Vec<_>>()
559 .join("-");
560 let trimmed: String = collapsed.chars().take(64).collect();
561 if trimmed.is_empty() {
562 "agent".to_string()
563 } else {
564 trimmed
565 }
566}
567
568fn default_agent_type() -> String {
569 "standalone".to_string()
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize)]
574pub struct AgentContent {
575 pub agent_id: String,
576 pub content_type: String,
577 pub content: String,
578 pub updated_at: i64,
579}
580
581#[derive(Debug, Clone, Serialize, Deserialize)]
583pub struct AgentSkill {
584 pub id: String,
585 pub agent_id: String,
586 pub name: String,
587 pub trigger: String,
588 pub skill_type: String,
589 pub description: String,
590 pub content: String,
591 pub created_at: i64,
592}
593
594#[derive(Debug, Clone, Serialize, Deserialize)]
596pub struct AgentHistory {
597 pub id: i64,
598 pub agent_id: String,
599 pub session_date: String,
600 pub entry: String,
601 pub timestamp: i64,
602}
603
604impl WaveStore {
605 pub fn user_clone_defs_for_template(
645 &self,
646 template_id: &str,
647 ) -> Result<Vec<AgentDefinition>, StoreError> {
648 let conn = self.conn.lock().unwrap();
649 let mut stmt = conn.prepare(
650 "SELECT id, slug, name, icon, provider, description,
651 working_directory, shell, provider_flags, auto_start,
652 restart_on_crash, idle_timeout_minutes, created_at,
653 agent_type, environment, agent_bus_id, is_seeded,
654 accounts, parent_id, branch_label, updated_at,
655 user_hidden
656 FROM db_agent_definitions
657 WHERE is_seeded = 0 AND parent_id = ?1
658 ORDER BY updated_at DESC, created_at DESC",
659 )?;
660 let rows = stmt.query_map(params![template_id], map_agent_definition_row)?;
661 let mut out = Vec::new();
662 for r in rows {
663 out.push(r?);
664 }
665 Ok(out)
666 }
667
668 pub fn agent_def_get(&self, id: &str) -> Result<Option<AgentDefinition>, StoreError> {
680 let conn = self.conn.lock().unwrap();
681 let mut stmt = conn.prepare(
682 "SELECT id, slug, name, icon, provider, description,
683 working_directory, shell, provider_flags, auto_start,
684 restart_on_crash, idle_timeout_minutes, created_at,
685 agent_type, environment, agent_bus_id, is_seeded,
686 accounts, parent_id, branch_label, updated_at,
687 user_hidden
688 FROM db_agent_definitions
689 WHERE id = ?1",
690 )?;
691 let mut rows = stmt.query_map(params![id], map_agent_definition_row)?;
692 match rows.next() {
693 Some(row) => Ok(Some(row?)),
694 None => Ok(None),
695 }
696 }
697
698 pub fn agent_def_list(&self) -> Result<Vec<AgentDefinition>, StoreError> {
699 let conn = self.conn.lock().unwrap();
700 let mut stmt = conn.prepare(
701 "SELECT id, slug, name, icon, provider, description,
702 working_directory, shell, provider_flags, auto_start,
703 restart_on_crash, idle_timeout_minutes, created_at,
704 agent_type, environment, agent_bus_id, is_seeded,
705 accounts, parent_template_id, branch_label, updated_at,
706 user_hidden
707 FROM db_agents
708 ORDER BY updated_at DESC, created_at ASC",
709 )?;
710 let rows = stmt.query_map([], map_agent_definition_row)?;
711 let mut agents = Vec::new();
712 for row in rows {
713 agents.push(row?);
714 }
715 Ok(agents)
716 }
717
718 pub fn agent_def_count(&self) -> Result<i64, StoreError> {
726 let conn = self.conn.lock().unwrap();
727 let count: i64 = conn.query_row(
728 "SELECT COUNT(*) FROM db_agents",
729 [],
730 |row| row.get(0),
731 )?;
732 Ok(count)
733 }
734
735 pub fn agent_def_delete_seeded(&self) -> Result<usize, StoreError> {
737 let (rows, cascaded_inst_ids) = {
745 let conn = self.conn.lock().unwrap();
746 let mut stmt = conn.prepare(
747 "SELECT i.id FROM db_agent_instances i
748 INNER JOIN db_agent_definitions d ON i.definition_id = d.id
749 WHERE d.is_seeded = 1",
750 )?;
751 let ids: Vec<String> = stmt
752 .query_map([], |r| r.get::<_, String>(0))?
753 .collect::<Result<Vec<_>, _>>()?;
754 drop(stmt);
755 let rows = conn.execute("DELETE FROM db_agent_definitions WHERE is_seeded=1", [])?;
756 (rows, ids)
757 };
758 self.agents_dual_write_seeded_delete(&cascaded_inst_ids)?;
764 Ok(rows)
765 }
766
767 pub fn agent_def_insert(&self, agent: &mut AgentDefinition) -> Result<(), StoreError> {
777 let conn = self.conn.lock().unwrap();
778 let base = if agent.slug.is_empty() {
779 derive_slug(&agent.name)
780 } else {
781 agent.slug.clone()
782 };
783 let mut candidate = base.clone();
791 let mut n: u32 = 2;
792 loop {
793 let count: i64 = conn.query_row(
794 "SELECT COUNT(*) FROM db_agents WHERE slug = ?1",
795 params![candidate],
796 |row| row.get(0),
797 )?;
798 if count == 0 {
799 break;
800 }
801 candidate = format!("{}-{}", base, n);
802 n += 1;
803 }
804 agent.slug = candidate;
805 conn.execute(
806 "INSERT INTO db_agent_definitions (id, slug, name, icon, provider, description,
807 working_directory, shell, provider_flags, auto_start, restart_on_crash,
808 idle_timeout_minutes, created_at, agent_type, environment, agent_bus_id,
809 is_seeded, accounts, parent_id, branch_label, updated_at, user_hidden)
810 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16,
811 ?17, ?18, ?19, ?20, ?21, ?22)",
812 params![
813 agent.id,
814 agent.slug,
815 agent.name,
816 agent.icon,
817 agent.provider,
818 agent.description,
819 agent.working_directory,
820 agent.shell,
821 agent.provider_flags,
822 agent.auto_start,
823 agent.restart_on_crash,
824 agent.idle_timeout_minutes,
825 agent.created_at,
826 agent.agent_type,
827 agent.environment,
828 agent.agent_bus_id,
829 agent.is_seeded,
830 agent.accounts,
831 agent.parent_id,
832 agent.branch_label,
833 agent.created_at,
835 agent.user_hidden,
842 ],
843 )?;
844 let stamped_updated_at = agent.created_at;
847 drop(conn);
848 let mut snapshot = agent.clone();
849 snapshot.updated_at = stamped_updated_at;
850 self.agents_dual_write_definition_upsert(&snapshot)?;
853 let _ = stamped_updated_at;
861 Ok(())
862 }
863
864 pub fn agent_def_set_hidden(&self, id: &str, hidden: bool) -> Result<bool, StoreError> {
877 let conn = self.conn.lock().unwrap();
878 let is_template: i64 = match conn.query_row(
883 "SELECT is_template FROM db_agents WHERE id = ?1",
884 params![id],
885 |row| row.get(0),
886 ) {
887 Ok(v) => v,
888 Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(false),
889 Err(e) => return Err(StoreError::Sqlite(e)),
890 };
891 if is_template != 1 {
892 return Err(StoreError::Other(format!(
893 "agent_def_set_hidden: {id} is not a seeded template (is_template={is_template}); \
894 user-owned definitions must use delete/archive paths, not hide"
895 )));
896 }
897 let rows = conn.execute(
901 "UPDATE db_agent_definitions SET user_hidden = ?1 WHERE id = ?2",
902 params![if hidden { 1_i64 } else { 0_i64 }, id],
903 )?;
904 if rows > 0 {
905 if let Err(e) = conn.execute(
909 "UPDATE db_agents SET user_hidden = ?1 WHERE id = ?2 AND is_template = 1",
910 params![if hidden { 1_i64 } else { 0_i64 }, id],
911 ) {
912 tracing::error!(
913 id = %id,
914 hidden,
915 error = %e,
916 "db_agents dual-write: template hide flag mirror failed",
917 );
918 }
919 }
920 Ok(rows > 0)
921 }
922
923 pub fn agent_def_update(&self, agent: &mut AgentDefinition) -> Result<bool, StoreError> {
932 let now = std::time::SystemTime::now()
933 .duration_since(std::time::UNIX_EPOCH)
934 .unwrap_or_default()
935 .as_millis() as i64;
936 let rows = {
937 let conn = self.conn.lock().unwrap();
938 conn.execute(
939 "UPDATE db_agent_definitions SET name=?1, icon=?2, provider=?3, description=?4,
940 working_directory=?5, shell=?6, provider_flags=?7, auto_start=?8,
941 restart_on_crash=?9, idle_timeout_minutes=?10,
942 agent_type=?11, environment=?12, agent_bus_id=?13, accounts=?14, updated_at=?15
943 WHERE id=?16",
944 params![
945 agent.name,
946 agent.icon,
947 agent.provider,
948 agent.description,
949 agent.working_directory,
950 agent.shell,
951 agent.provider_flags,
952 agent.auto_start,
953 agent.restart_on_crash,
954 agent.idle_timeout_minutes,
955 agent.agent_type,
956 agent.environment,
957 agent.agent_bus_id,
958 agent.accounts,
959 now,
960 agent.id
961 ],
962 )?
963 };
964 agent.updated_at = now;
967 if rows > 0 {
970 self.agents_dual_write_definition_upsert(agent)?;
971 }
972 Ok(rows > 0)
973 }
974
975 pub fn agent_def_delete(&self, id: &str) -> Result<bool, StoreError> {
977 let (cascaded_instance_ids, rows) = {
984 let conn = self.conn.lock().unwrap();
985 let cascaded_instance_ids: Vec<String> = {
986 let mut stmt = conn
987 .prepare("SELECT id FROM db_agent_instances WHERE definition_id = ?1")?;
988 let iter = stmt.query_map(params![id], |row| row.get::<_, String>(0))?;
989 iter.collect::<Result<Vec<_>, _>>()?
990 };
991 let rows = conn.execute(
992 "DELETE FROM db_agent_definitions WHERE id=?1",
993 params![id],
994 )?;
995 (cascaded_instance_ids, rows)
996 };
997 if rows > 0 {
998 if let Some(reg) = self.registry() {
999 for instance_id in &cascaded_instance_ids {
1000 if let Err(e) = reg.hard_delete(instance_id) {
1001 tracing::warn!(
1002 instance_id = %instance_id,
1003 agent_def_id = %id,
1004 error = %e,
1005 "registry: failed to mirror agent_def_delete cascade"
1006 );
1007 }
1008 }
1009 }
1010 self.agents_dual_write_definition_delete(id)?;
1014 for instance_id in &cascaded_instance_ids {
1015 self.agents_dual_write_instance_delete(instance_id)?;
1016 }
1017 }
1018 Ok(rows > 0)
1019 }
1020
1021 pub fn agent_content_get(&self, agent_id: &str, content_type: &str) -> Result<Option<AgentContent>, StoreError> {
1025 let conn = self.conn.lock().unwrap();
1026 let mut stmt = conn.prepare(
1027 "SELECT agent_id, content_type, content, updated_at
1028 FROM db_agent_content WHERE agent_id=?1 AND content_type=?2",
1029 )?;
1030 let result = stmt.query_row(params![agent_id, content_type], |row| {
1031 Ok(AgentContent {
1032 agent_id: row.get(0)?,
1033 content_type: row.get(1)?,
1034 content: row.get(2)?,
1035 updated_at: row.get(3)?,
1036 })
1037 });
1038 match result {
1039 Ok(content) => Ok(Some(content)),
1040 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1041 Err(e) => Err(StoreError::Sqlite(e)),
1042 }
1043 }
1044
1045 pub fn agent_content_set(&self, content: &AgentContent) -> Result<(), StoreError> {
1047 let conn = self.conn.lock().unwrap();
1048 conn.execute(
1049 "INSERT INTO db_agent_content (agent_id, content_type, content, updated_at)
1050 VALUES (?1, ?2, ?3, ?4)
1051 ON CONFLICT(agent_id, content_type) DO UPDATE SET content=?3, updated_at=?4",
1052 params![
1053 content.agent_id,
1054 content.content_type,
1055 content.content,
1056 content.updated_at,
1057 ],
1058 )?;
1059 Ok(())
1060 }
1061
1062 pub fn agent_content_get_all(&self, agent_id: &str) -> Result<Vec<AgentContent>, StoreError> {
1064 let conn = self.conn.lock().unwrap();
1065 let mut stmt = conn.prepare(
1066 "SELECT agent_id, content_type, content, updated_at
1067 FROM db_agent_content WHERE agent_id=?1 ORDER BY content_type ASC",
1068 )?;
1069 let rows = stmt.query_map(params![agent_id], |row| {
1070 Ok(AgentContent {
1071 agent_id: row.get(0)?,
1072 content_type: row.get(1)?,
1073 content: row.get(2)?,
1074 updated_at: row.get(3)?,
1075 })
1076 })?;
1077 let mut contents = Vec::new();
1078 for row in rows {
1079 contents.push(row?);
1080 }
1081 Ok(contents)
1082 }
1083
1084 #[allow(dead_code)]
1086 pub fn agent_content_delete(&self, agent_id: &str, content_type: &str) -> Result<bool, StoreError> {
1087 let conn = self.conn.lock().unwrap();
1088 let rows = conn.execute(
1089 "DELETE FROM db_agent_content WHERE agent_id=?1 AND content_type=?2",
1090 params![agent_id, content_type],
1091 )?;
1092 Ok(rows > 0)
1093 }
1094
1095 pub fn agent_skill_list(&self, agent_id: &str) -> Result<Vec<AgentSkill>, StoreError> {
1099 let conn = self.conn.lock().unwrap();
1100 let mut stmt = conn.prepare(
1101 "SELECT id, agent_id, name, trigger, skill_type, description, content, created_at
1102 FROM db_agent_skills WHERE agent_id=?1 ORDER BY created_at ASC",
1103 )?;
1104 let rows = stmt.query_map(params![agent_id], |row| {
1105 Ok(AgentSkill {
1106 id: row.get(0)?,
1107 agent_id: row.get(1)?,
1108 name: row.get(2)?,
1109 trigger: row.get(3)?,
1110 skill_type: row.get(4)?,
1111 description: row.get(5)?,
1112 content: row.get(6)?,
1113 created_at: row.get(7)?,
1114 })
1115 })?;
1116 let mut skills = Vec::new();
1117 for row in rows {
1118 skills.push(row?);
1119 }
1120 Ok(skills)
1121 }
1122
1123 pub fn agent_skill_get(&self, id: &str) -> Result<Option<AgentSkill>, StoreError> {
1125 let conn = self.conn.lock().unwrap();
1126 let mut stmt = conn.prepare(
1127 "SELECT id, agent_id, name, trigger, skill_type, description, content, created_at
1128 FROM db_agent_skills WHERE id=?1",
1129 )?;
1130 let result = stmt.query_row(params![id], |row| {
1131 Ok(AgentSkill {
1132 id: row.get(0)?,
1133 agent_id: row.get(1)?,
1134 name: row.get(2)?,
1135 trigger: row.get(3)?,
1136 skill_type: row.get(4)?,
1137 description: row.get(5)?,
1138 content: row.get(6)?,
1139 created_at: row.get(7)?,
1140 })
1141 });
1142 match result {
1143 Ok(skill) => Ok(Some(skill)),
1144 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1145 Err(e) => Err(StoreError::Sqlite(e)),
1146 }
1147 }
1148
1149 pub fn agent_skill_insert(&self, skill: &AgentSkill) -> Result<(), StoreError> {
1151 let conn = self.conn.lock().unwrap();
1152 conn.execute(
1153 "INSERT INTO db_agent_skills (id, agent_id, name, trigger, skill_type, description, content, created_at)
1154 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1155 params![
1156 skill.id,
1157 skill.agent_id,
1158 skill.name,
1159 skill.trigger,
1160 skill.skill_type,
1161 skill.description,
1162 skill.content,
1163 skill.created_at
1164 ],
1165 )?;
1166 Ok(())
1167 }
1168
1169 pub fn agent_skill_update(&self, skill: &AgentSkill) -> Result<bool, StoreError> {
1171 let conn = self.conn.lock().unwrap();
1172 let rows = conn.execute(
1173 "UPDATE db_agent_skills SET name=?1, trigger=?2, skill_type=?3, description=?4, content=?5
1174 WHERE id=?6",
1175 params![
1176 skill.name,
1177 skill.trigger,
1178 skill.skill_type,
1179 skill.description,
1180 skill.content,
1181 skill.id
1182 ],
1183 )?;
1184 Ok(rows > 0)
1185 }
1186
1187 pub fn agent_skill_delete(&self, id: &str) -> Result<bool, StoreError> {
1189 let conn = self.conn.lock().unwrap();
1190 let rows = conn.execute(
1191 "DELETE FROM db_agent_skills WHERE id=?1",
1192 params![id],
1193 )?;
1194 Ok(rows > 0)
1195 }
1196
1197 pub fn agent_history_append(&self, agent_id: &str, entry: &str) -> Result<AgentHistory, StoreError> {
1201 let now = std::time::SystemTime::now()
1202 .duration_since(std::time::UNIX_EPOCH)
1203 .unwrap_or_default()
1204 .as_millis() as i64;
1205 let secs = (now / 1000) as u64;
1207 let days = secs / 86400;
1208 let session_date = format_epoch_date(days);
1210 let conn = self.conn.lock().unwrap();
1211 conn.execute(
1212 "INSERT INTO db_agent_history (agent_id, session_date, entry, timestamp) VALUES (?1, ?2, ?3, ?4)",
1213 params![agent_id, session_date, entry, now],
1214 )?;
1215 let id = conn.last_insert_rowid();
1216 Ok(AgentHistory {
1217 id,
1218 agent_id: agent_id.to_string(),
1219 session_date,
1220 entry: entry.to_string(),
1221 timestamp: now,
1222 })
1223 }
1224
1225 pub fn agent_history_list(
1227 &self,
1228 agent_id: &str,
1229 session_date: Option<&str>,
1230 limit: i64,
1231 offset: i64,
1232 ) -> Result<Vec<AgentHistory>, StoreError> {
1233 let conn = self.conn.lock().unwrap();
1234 let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match session_date {
1235 Some(date) => (
1236 "SELECT id, agent_id, session_date, entry, timestamp
1237 FROM db_agent_history WHERE agent_id=?1 AND session_date=?2
1238 ORDER BY timestamp DESC LIMIT ?3 OFFSET ?4".to_string(),
1239 vec![
1240 Box::new(agent_id.to_string()),
1241 Box::new(date.to_string()),
1242 Box::new(limit),
1243 Box::new(offset),
1244 ],
1245 ),
1246 None => (
1247 "SELECT id, agent_id, session_date, entry, timestamp
1248 FROM db_agent_history WHERE agent_id=?1
1249 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3".to_string(),
1250 vec![
1251 Box::new(agent_id.to_string()),
1252 Box::new(limit),
1253 Box::new(offset),
1254 ],
1255 ),
1256 };
1257 let params_refs: Vec<&dyn rusqlite::types::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1258 let mut stmt = conn.prepare(&sql)?;
1259 let rows = stmt.query_map(params_refs.as_slice(), |row| {
1260 Ok(AgentHistory {
1261 id: row.get(0)?,
1262 agent_id: row.get(1)?,
1263 session_date: row.get(2)?,
1264 entry: row.get(3)?,
1265 timestamp: row.get(4)?,
1266 })
1267 })?;
1268 let mut entries = Vec::new();
1269 for row in rows {
1270 entries.push(row?);
1271 }
1272 Ok(entries)
1273 }
1274
1275 pub fn agent_history_search(
1277 &self,
1278 agent_id: &str,
1279 query: &str,
1280 limit: i64,
1281 ) -> Result<Vec<AgentHistory>, StoreError> {
1282 let conn = self.conn.lock().unwrap();
1283 let pattern = format!("%{}%", query);
1284 let mut stmt = conn.prepare(
1285 "SELECT id, agent_id, session_date, entry, timestamp
1286 FROM db_agent_history WHERE agent_id=?1 AND entry LIKE ?2
1287 ORDER BY timestamp DESC LIMIT ?3",
1288 )?;
1289 let rows = stmt.query_map(params![agent_id, pattern, limit], |row| {
1290 Ok(AgentHistory {
1291 id: row.get(0)?,
1292 agent_id: row.get(1)?,
1293 session_date: row.get(2)?,
1294 entry: row.get(3)?,
1295 timestamp: row.get(4)?,
1296 })
1297 })?;
1298 let mut entries = Vec::new();
1299 for row in rows {
1300 entries.push(row?);
1301 }
1302 Ok(entries)
1303 }
1304}
1305
1306fn format_epoch_date(days_since_epoch: u64) -> String {
1309 let z = days_since_epoch + 719468;
1311 let era = z / 146097;
1312 let doe = z - era * 146097;
1313 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1314 let y = yoe + era * 400;
1315 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1316 let mp = (5 * doy + 2) / 153;
1317 let d = doy - (153 * mp + 2) / 5 + 1;
1318 let m = if mp < 10 { mp + 3 } else { mp - 9 };
1319 let y = if m <= 2 { y + 1 } else { y };
1320 format!("{:04}-{:02}-{:02}", y, m, d)
1321}
1322
1323#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1335#[serde(tag = "backend", rename_all = "snake_case")]
1336pub enum SecretRef {
1337 Env {
1338 env_var: String,
1339 },
1340 SecretsManager {
1341 sm_path: String,
1342 #[serde(default, skip_serializing_if = "Option::is_none")]
1343 sm_json_path: Option<String>,
1344 },
1345 PlaintextDev {
1346 plaintext_dev: String,
1347 },
1348 OAuthConfigDir {
1357 dir: String,
1362 },
1363}
1364
1365#[derive(Debug, Clone, Serialize, Deserialize)]
1369pub struct IdentityAccount {
1370 pub id: String,
1371 pub name: String,
1372 pub provider: String, pub kind: String, #[serde(default)]
1375 pub display_name: String,
1376 pub secret_ref: SecretRef,
1377 #[serde(default = "default_context_json")]
1380 pub context: serde_json::Value,
1381 #[serde(default = "default_identity_status")]
1382 pub status: String, pub created_at: i64,
1384 pub updated_at: i64,
1385}
1386
1387fn default_context_json() -> serde_json::Value {
1388 serde_json::json!({})
1389}
1390
1391fn default_identity_status() -> String {
1392 "unknown".to_string()
1393}
1394
1395#[derive(Debug, Clone, Serialize, Deserialize)]
1397pub struct AgentIdentityLink {
1398 pub agent_id: String,
1399 pub account_id: String,
1400 pub provider: String,
1401}
1402
1403#[derive(Debug, Clone, Serialize, Deserialize)]
1411pub struct Identity {
1412 pub id: String,
1413 pub name: String,
1414 #[serde(default)]
1415 pub description: String,
1416 #[serde(default)]
1417 pub is_blank: bool,
1418 pub created_at: i64,
1419 pub updated_at: i64,
1420}
1421
1422#[derive(Debug, Clone, Serialize, Deserialize)]
1424pub struct IdentityBinding {
1425 pub identity_id: String,
1426 pub provider: String,
1427 pub account_id: String,
1428}
1429
1430#[derive(Debug, Clone, Serialize, Deserialize)]
1435pub struct Memory {
1436 pub id: String,
1437 pub name: String,
1438 #[serde(default)]
1439 pub description: String,
1440 #[serde(default)]
1441 pub is_blank: bool,
1442 #[serde(default)]
1444 pub provider: String,
1445 #[serde(default)]
1446 pub model: String,
1447 #[serde(default)]
1448 pub instructions: String,
1449 #[serde(default = "default_json_array_string")]
1451 pub context_files: String,
1452 #[serde(default = "default_json_array_string")]
1454 pub mcp_servers: String,
1455 #[serde(default = "default_json_array_string")]
1457 pub skills: String,
1458 pub created_at: i64,
1459 pub updated_at: i64,
1460}
1461
1462fn default_json_array_string() -> String {
1463 "[]".to_string()
1464}
1465
1466#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1468#[serde(rename_all = "lowercase")]
1469pub enum InstanceStatus {
1470 Running,
1471 Paused,
1472 Stopped,
1473 Crashed,
1474 Detached,
1475}
1476
1477impl InstanceStatus {
1478 pub fn as_str(&self) -> &'static str {
1479 match self {
1480 Self::Running => "running",
1481 Self::Paused => "paused",
1482 Self::Stopped => "stopped",
1483 Self::Crashed => "crashed",
1484 Self::Detached => "detached",
1485 }
1486 }
1487 pub fn parse(s: &str) -> Option<Self> {
1488 match s {
1489 "running" => Some(Self::Running),
1490 "paused" => Some(Self::Paused),
1491 "stopped" => Some(Self::Stopped),
1492 "crashed" => Some(Self::Crashed),
1493 "detached" => Some(Self::Detached),
1494 _ => None,
1495 }
1496 }
1497}
1498
1499#[derive(Debug, Clone, Serialize, Deserialize)]
1503pub struct GitHubContext {
1504 pub repo: String, #[serde(default, skip_serializing_if = "Option::is_none")]
1506 pub pr_number: Option<u32>,
1507 #[serde(default, skip_serializing_if = "Option::is_none")]
1508 pub branch: Option<String>,
1509 #[serde(default, skip_serializing_if = "Option::is_none")]
1510 pub issue_number: Option<u32>,
1511 #[serde(default, skip_serializing_if = "Option::is_none")]
1512 pub workflow_run_id: Option<u64>,
1513}
1514
1515#[derive(Debug, Clone, Serialize, Deserialize)]
1521pub struct AgentInstance {
1522 pub id: String,
1523 pub definition_id: String,
1524 #[serde(default)]
1525 pub parent_instance_id: String,
1526 #[serde(default)]
1527 pub block_id: String,
1528 #[serde(default)]
1529 pub session_id: String,
1530 pub status: String,
1531 #[serde(default)]
1533 pub github_context: String,
1534 pub started_at: i64,
1535 #[serde(default)]
1536 pub ended_at: i64,
1537 pub created_at: i64,
1538 #[serde(default)]
1542 pub identity_id: String,
1543 #[serde(default)]
1547 pub memory_id: String,
1548 #[serde(default)]
1552 pub instance_name: String,
1553 #[serde(default)]
1558 pub working_directory: String,
1559 #[serde(default)]
1562 pub display_hidden: bool,
1563}
1564
1565fn agent_instance_to_record(
1573 inst: &AgentInstance,
1574 agents_root: &Path,
1575) -> Result<crate::registry::NamedAgentRecord, String> {
1576 use crate::registry::{NamedAgentRecord, NamedAgentRecordV1, MAX_SUPPORTED_SCHEMA};
1577 let rel = relative_workdir(&inst.working_directory, agents_root).ok_or_else(|| {
1578 format!(
1579 "working_directory {:?} is not under {:?}",
1580 inst.working_directory,
1581 agents_root.display()
1582 )
1583 })?;
1584 let version = env!("CARGO_PKG_VERSION").to_string();
1585 Ok(NamedAgentRecord {
1586 schema_version: MAX_SUPPORTED_SCHEMA,
1587 data: NamedAgentRecordV1 {
1588 instance_id: inst.id.clone(),
1589 instance_name: inst.instance_name.clone(),
1590 definition_id: inst.definition_id.clone(),
1591 identity_id: empty_to_none(&inst.identity_id),
1592 memory_id: empty_to_none(&inst.memory_id),
1593 working_dir: rel,
1594 created_at_ms: inst.created_at,
1595 last_launched_at_ms: inst.started_at,
1596 created_by_version: version.clone(),
1597 last_launched_by_version: version,
1598 },
1599 })
1600}
1601
1602fn empty_to_none(s: &str) -> Option<String> {
1603 if s.is_empty() { None } else { Some(s.to_string()) }
1604}
1605
1606fn relative_workdir(abs: &str, agents_root: &Path) -> Option<String> {
1613 if abs.is_empty() {
1614 return None;
1615 }
1616 let p = std::path::Path::new(abs);
1617 let rel = p.strip_prefix(agents_root).ok()?;
1618 let s = rel.to_string_lossy().to_string();
1622 if s.is_empty() || s == "." {
1623 return None;
1624 }
1625 Some(s)
1626}
1627
1628impl WaveStore {
1629 pub fn identity_list(
1635 &self,
1636 provider: Option<&str>,
1637 ) -> Result<Vec<IdentityAccount>, StoreError> {
1638 let conn = self.conn.lock().unwrap();
1639 let mut rows_vec = Vec::new();
1640 let map_row = |row: &rusqlite::Row| -> rusqlite::Result<IdentityAccount> {
1641 let secret_ref_json: String = row.get(5)?;
1642 let context_json: String = row.get(6)?;
1643 Ok(IdentityAccount {
1644 id: row.get(0)?,
1645 name: row.get(1)?,
1646 provider: row.get(2)?,
1647 kind: row.get(3)?,
1648 display_name: row.get(4)?,
1649 secret_ref: serde_json::from_str(&secret_ref_json).map_err(|e| {
1650 rusqlite::Error::FromSqlConversionFailure(
1651 5,
1652 rusqlite::types::Type::Text,
1653 Box::new(e),
1654 )
1655 })?,
1656 context: serde_json::from_str(&context_json).unwrap_or_else(|_| serde_json::json!({})),
1657 status: row.get(7)?,
1658 created_at: row.get(8)?,
1659 updated_at: row.get(9)?,
1660 })
1661 };
1662 match provider {
1663 Some(p) => {
1664 let mut stmt = conn.prepare(
1665 "SELECT id, name, provider, kind, display_name, secret_ref, context,
1666 status, created_at, updated_at
1667 FROM db_identity_accounts
1668 WHERE provider = ?1
1669 ORDER BY updated_at DESC",
1670 )?;
1671 let iter = stmt.query_map(params![p], map_row)?;
1672 for r in iter {
1673 rows_vec.push(r?);
1674 }
1675 }
1676 None => {
1677 let mut stmt = conn.prepare(
1678 "SELECT id, name, provider, kind, display_name, secret_ref, context,
1679 status, created_at, updated_at
1680 FROM db_identity_accounts
1681 ORDER BY updated_at DESC",
1682 )?;
1683 let iter = stmt.query_map([], map_row)?;
1684 for r in iter {
1685 rows_vec.push(r?);
1686 }
1687 }
1688 }
1689 Ok(rows_vec)
1690 }
1691
1692 pub fn identity_get(&self, id: &str) -> Result<Option<IdentityAccount>, StoreError> {
1693 let conn = self.conn.lock().unwrap();
1694 let mut stmt = conn.prepare(
1695 "SELECT id, name, provider, kind, display_name, secret_ref, context,
1696 status, created_at, updated_at
1697 FROM db_identity_accounts WHERE id = ?1",
1698 )?;
1699 let result = stmt.query_row(params![id], |row| {
1700 let secret_ref_json: String = row.get(5)?;
1701 let context_json: String = row.get(6)?;
1702 Ok(IdentityAccount {
1703 id: row.get(0)?,
1704 name: row.get(1)?,
1705 provider: row.get(2)?,
1706 kind: row.get(3)?,
1707 display_name: row.get(4)?,
1708 secret_ref: serde_json::from_str(&secret_ref_json).map_err(|e| {
1709 rusqlite::Error::FromSqlConversionFailure(
1710 5,
1711 rusqlite::types::Type::Text,
1712 Box::new(e),
1713 )
1714 })?,
1715 context: serde_json::from_str(&context_json).unwrap_or_else(|_| serde_json::json!({})),
1716 status: row.get(7)?,
1717 created_at: row.get(8)?,
1718 updated_at: row.get(9)?,
1719 })
1720 });
1721 match result {
1722 Ok(a) => Ok(Some(a)),
1723 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1724 Err(e) => Err(e.into()),
1725 }
1726 }
1727
1728 pub fn identity_upsert(&self, account: &IdentityAccount) -> Result<(), StoreError> {
1732 let conn = self.conn.lock().unwrap();
1733 let secret_ref_json = serde_json::to_string(&account.secret_ref)?;
1734 let context_json = serde_json::to_string(&account.context)?;
1735 conn.execute(
1736 "INSERT INTO db_identity_accounts
1737 (id, name, provider, kind, display_name, secret_ref, context,
1738 status, created_at, updated_at)
1739 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
1740 ON CONFLICT(id) DO UPDATE SET
1741 name = excluded.name,
1742 provider = excluded.provider,
1743 kind = excluded.kind,
1744 display_name = excluded.display_name,
1745 secret_ref = excluded.secret_ref,
1746 context = excluded.context,
1747 status = excluded.status,
1748 updated_at = excluded.updated_at",
1749 params![
1750 account.id,
1751 account.name,
1752 account.provider,
1753 account.kind,
1754 account.display_name,
1755 secret_ref_json,
1756 context_json,
1757 account.status,
1758 account.created_at,
1759 account.updated_at,
1760 ],
1761 )?;
1762 Ok(())
1763 }
1764
1765 pub fn identity_delete(&self, id: &str) -> Result<bool, StoreError> {
1766 let conn = self.conn.lock().unwrap();
1767 let rows = conn.execute("DELETE FROM db_identity_accounts WHERE id = ?1", params![id])?;
1768 Ok(rows > 0)
1769 }
1770
1771 pub fn agent_identity_link(
1777 &self,
1778 agent_id: &str,
1779 account_id: &str,
1780 provider: &str,
1781 ) -> Result<(), StoreError> {
1782 let conn = self.conn.lock().unwrap();
1783 conn.execute(
1784 "INSERT INTO db_agent_identity_links (agent_id, account_id, provider)
1785 VALUES (?1, ?2, ?3)
1786 ON CONFLICT(agent_id, provider) DO UPDATE SET account_id = excluded.account_id",
1787 params![agent_id, account_id, provider],
1788 )?;
1789 Ok(())
1790 }
1791
1792 pub fn agent_identity_unlink(
1795 &self,
1796 agent_id: &str,
1797 provider: &str,
1798 ) -> Result<bool, StoreError> {
1799 let conn = self.conn.lock().unwrap();
1800 let rows = conn.execute(
1801 "DELETE FROM db_agent_identity_links WHERE agent_id = ?1 AND provider = ?2",
1802 params![agent_id, provider],
1803 )?;
1804 Ok(rows > 0)
1805 }
1806
1807 pub fn agent_identity_list_for_agent(
1809 &self,
1810 agent_id: &str,
1811 ) -> Result<Vec<AgentIdentityLink>, StoreError> {
1812 let conn = self.conn.lock().unwrap();
1813 let mut stmt = conn.prepare(
1814 "SELECT agent_id, account_id, provider
1815 FROM db_agent_identity_links
1816 WHERE agent_id = ?1
1817 ORDER BY provider",
1818 )?;
1819 let iter = stmt.query_map(params![agent_id], |row| {
1820 Ok(AgentIdentityLink {
1821 agent_id: row.get(0)?,
1822 account_id: row.get(1)?,
1823 provider: row.get(2)?,
1824 })
1825 })?;
1826 let mut out = Vec::new();
1827 for r in iter {
1828 out.push(r?);
1829 }
1830 Ok(out)
1831 }
1832
1833 pub fn instance_list(
1838 &self,
1839 definition_id: Option<&str>,
1840 status: Option<&str>,
1841 ) -> Result<Vec<AgentInstance>, StoreError> {
1842 let conn = self.conn.lock().unwrap();
1843 let mut sql = String::from(
1846 "SELECT id, definition_id, parent_instance_id, block_id, session_id,
1847 status, github_context, started_at, ended_at, created_at,
1848 identity_id, memory_id, instance_name, working_directory,
1849 display_hidden
1850 FROM db_agent_instances",
1851 );
1852 let mut clauses: Vec<&str> = Vec::new();
1853 if definition_id.is_some() {
1854 clauses.push("definition_id = ?");
1855 }
1856 if status.is_some() {
1857 clauses.push("status = ?");
1858 }
1859 if !clauses.is_empty() {
1860 sql.push_str(" WHERE ");
1861 sql.push_str(&clauses.join(" AND "));
1862 }
1863 sql.push_str(" ORDER BY created_at DESC");
1864
1865 let mut stmt = conn.prepare(&sql)?;
1866 let mut param_vals: Vec<String> = Vec::new();
1870 if let Some(d) = definition_id {
1871 param_vals.push(d.to_string());
1872 }
1873 if let Some(s) = status {
1874 param_vals.push(s.to_string());
1875 }
1876 let iter = stmt.query_map(rusqlite::params_from_iter(param_vals.iter()), map_instance_row)?;
1877 let mut out = Vec::new();
1878 for r in iter {
1879 out.push(r?);
1880 }
1881 Ok(out)
1882 }
1883
1884 pub fn instance_get(&self, id: &str) -> Result<Option<AgentInstance>, StoreError> {
1885 let conn = self.conn.lock().unwrap();
1886 let mut stmt = conn.prepare(
1887 "SELECT id, definition_id, parent_instance_id, block_id, session_id,
1888 status, github_context, started_at, ended_at, created_at,
1889 identity_id, memory_id, instance_name, working_directory,
1890 display_hidden
1891 FROM db_agent_instances WHERE id = ?1",
1892 )?;
1893 let result = stmt.query_row(params![id], map_instance_row);
1894 match result {
1895 Ok(a) => Ok(Some(a)),
1896 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1897 Err(e) => Err(e.into()),
1898 }
1899 }
1900
1901 pub fn instance_create(&self, inst: &AgentInstance) -> Result<(), StoreError> {
1903 {
1904 let conn = self.conn.lock().unwrap();
1905 conn.execute(
1906 "INSERT INTO db_agent_instances
1907 (id, definition_id, parent_instance_id, block_id, session_id, status,
1908 github_context, started_at, ended_at, created_at,
1909 identity_id, memory_id,
1910 instance_name, working_directory, display_hidden)
1911 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12,
1912 ?13, ?14, ?15)",
1913 params![
1914 inst.id,
1915 inst.definition_id,
1916 inst.parent_instance_id,
1917 inst.block_id,
1918 inst.session_id,
1919 inst.status,
1920 inst.github_context,
1921 inst.started_at,
1922 inst.ended_at,
1923 inst.created_at,
1924 inst.identity_id,
1925 inst.memory_id,
1926 inst.instance_name,
1927 inst.working_directory,
1928 if inst.display_hidden { 1_i64 } else { 0_i64 },
1929 ],
1930 )?;
1931 }
1932 self.registry_upsert_if_named(inst);
1933 self.agents_dual_write_instance_create(inst)?;
1936 Ok(())
1937 }
1938
1939 pub fn instance_set_hidden(&self, id: &str, hidden: bool) -> Result<bool, StoreError> {
1949 let rows = {
1950 let conn = self.conn.lock().unwrap();
1951 conn.execute(
1952 "UPDATE db_agent_instances SET display_hidden = ?1 WHERE id = ?2",
1953 params![if hidden { 1_i64 } else { 0_i64 }, id],
1954 )?
1955 };
1956 let mut registry_acted = false;
1957 if let Some(reg) = self.registry() {
1958 if reg.exists_anywhere(id) {
1962 let res = if hidden {
1963 reg.retire(id)
1964 } else {
1965 reg.unretire(id)
1966 };
1967 match res {
1968 Ok(()) => registry_acted = true,
1969 Err(e) => tracing::warn!(
1970 instance_id = %id,
1971 hidden,
1972 error = %e,
1973 "registry: failed to mirror instance_set_hidden"
1974 ),
1975 }
1976 }
1977 }
1978 self.agents_dual_write_instance_set_hidden(id, hidden)?;
1981 Ok(rows > 0 || registry_acted)
1982 }
1983
1984 pub fn instance_list_named(
2026 &self,
2027 limit: usize,
2028 definition_id: Option<&str>,
2029 include_continuations: bool,
2030 ) -> Result<Vec<AgentInstance>, StoreError> {
2031 let conn = self.conn.lock().unwrap();
2032 let mut sql = String::from(
2037 "SELECT id, definition_id, parent_instance_id, block_id, session_id,
2038 status, github_context, started_at, ended_at, created_at,
2039 identity_id, memory_id, instance_name, working_directory,
2040 display_hidden
2041 FROM db_agent_instances
2042 WHERE display_hidden = 0
2043 AND instance_name <> ''",
2044 );
2045 if !include_continuations {
2046 sql.push_str("\n AND parent_instance_id = ''");
2047 }
2048 let limit_param_idx = if definition_id.is_some() {
2049 sql.push_str("\n AND definition_id = ?1");
2050 2
2051 } else {
2052 1
2053 };
2054 sql.push_str(&format!(
2055 "\n ORDER BY started_at DESC\n LIMIT ?{}",
2056 limit_param_idx
2057 ));
2058 let mut stmt = conn.prepare(&sql)?;
2059 let iter = match definition_id {
2060 Some(def) => stmt.query_map(params![def, limit as i64], map_instance_row)?,
2061 None => stmt.query_map(params![limit as i64], map_instance_row)?,
2062 };
2063 let mut out = Vec::new();
2064 for r in iter {
2065 out.push(r?);
2066 }
2067 Ok(out)
2068 }
2069
2070 pub fn instance_get_by_name(
2076 &self,
2077 instance_name: &str,
2078 ) -> Result<Option<AgentInstance>, StoreError> {
2079 if instance_name.is_empty() {
2080 return Ok(None);
2081 }
2082 let conn = self.conn.lock().unwrap();
2083 let mut stmt = conn.prepare(
2084 "SELECT id, definition_id, parent_instance_id, block_id, session_id,
2085 status, github_context, started_at, ended_at, created_at,
2086 identity_id, memory_id, instance_name, working_directory,
2087 display_hidden
2088 FROM db_agent_instances
2089 WHERE instance_name = ?1
2090 AND display_hidden = 0
2091 ORDER BY started_at DESC
2092 LIMIT 1",
2093 )?;
2094 let result = stmt.query_row(params![instance_name], map_instance_row);
2095 match result {
2096 Ok(a) => Ok(Some(a)),
2097 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
2098 Err(e) => Err(e.into()),
2099 }
2100 }
2101
2102 pub fn instance_update(&self, inst: &AgentInstance) -> Result<bool, StoreError> {
2106 let rows = {
2107 let conn = self.conn.lock().unwrap();
2108 conn.execute(
2109 "UPDATE db_agent_instances SET
2110 block_id = ?1,
2111 session_id = ?2,
2112 status = ?3,
2113 github_context = ?4,
2114 ended_at = ?5
2115 WHERE id = ?6",
2116 params![
2117 inst.block_id,
2118 inst.session_id,
2119 inst.status,
2120 inst.github_context,
2121 inst.ended_at,
2122 inst.id,
2123 ],
2124 )?
2125 };
2126 if rows > 0 {
2127 if let Ok(Some(fresh)) = self.instance_get(&inst.id) {
2132 self.registry_upsert_if_named(&fresh);
2133 self.agents_dual_write_instance_update(&fresh)?;
2137 }
2138 }
2139 Ok(rows > 0)
2140 }
2141
2142 pub fn instance_repoint_definition(
2154 &self,
2155 old_def_id: &str,
2156 new_def_id: &str,
2157 ) -> Result<usize, StoreError> {
2158 let rows = {
2159 let conn = self.conn.lock().unwrap();
2160 conn.execute(
2161 "UPDATE db_agent_instances SET definition_id = ?1 WHERE definition_id = ?2",
2162 params![new_def_id, old_def_id],
2163 )?
2164 };
2165 if rows > 0 {
2169 self.agents_dual_write_instance_repoint(old_def_id, new_def_id)?;
2170 }
2171 Ok(rows)
2172 }
2173
2174 pub fn instance_delete(&self, id: &str) -> Result<bool, StoreError> {
2175 let rows = {
2176 let conn = self.conn.lock().unwrap();
2177 conn.execute("DELETE FROM db_agent_instances WHERE id = ?1", params![id])?
2178 };
2179 if rows > 0 {
2180 if let Some(reg) = self.registry() {
2181 if let Err(e) = reg.hard_delete(id) {
2182 tracing::warn!(
2183 instance_id = %id,
2184 error = %e,
2185 "registry: failed to mirror instance_delete"
2186 );
2187 }
2188 }
2189 self.agents_dual_write_instance_delete(id)?;
2192 }
2193 Ok(rows > 0)
2194 }
2195
2196 pub fn instance_backfill_identity_id(
2213 &self,
2214 new_identity_id: &str,
2215 ) -> Result<usize, StoreError> {
2216 let rows = {
2217 let conn = self.conn.lock().unwrap();
2218 conn.execute(
2219 "UPDATE db_agent_instances
2220 SET identity_id = ?1
2221 WHERE identity_id = '' OR identity_id = 'blank'",
2222 params![new_identity_id],
2223 )?
2224 };
2225 self.agents_dual_write_backfill_identity(new_identity_id)?;
2228 Ok(rows)
2229 }
2230
2231 fn registry_upsert_if_named(&self, inst: &AgentInstance) {
2248 if inst.instance_name.is_empty() || !inst.parent_instance_id.is_empty() {
2262 return;
2263 }
2264 let Some(reg) = self.registry() else { return };
2265 let Some(agents_root) = reg.agents_root() else {
2266 tracing::warn!("registry: agents_root has no parent — skipping mirror");
2267 return;
2268 };
2269 let rec = match agent_instance_to_record(inst, agents_root) {
2270 Ok(rec) => rec,
2271 Err(e) => {
2272 tracing::warn!(
2273 instance_id = %inst.id,
2274 error = %e,
2275 "registry: instance not representable as record, skipping mirror"
2276 );
2277 return;
2278 }
2279 };
2280
2281 if let Err(e) = reg.unretire(&inst.id) {
2286 tracing::warn!(
2287 instance_id = %inst.id,
2288 error = %e,
2289 "registry: failed to unretire row before upsert"
2290 );
2291 }
2292
2293 if let Err(e) = reg.upsert(&rec) {
2294 tracing::warn!(
2295 instance_id = %inst.id,
2296 error = %e,
2297 "registry: failed to mirror instance_create/update"
2298 );
2299 return;
2300 }
2301
2302 if inst.display_hidden {
2306 if let Err(e) = reg.retire(&inst.id) {
2307 tracing::warn!(
2308 instance_id = %inst.id,
2309 error = %e,
2310 "registry: failed to retire hidden row post-upsert"
2311 );
2312 }
2313 }
2314 }
2315
2316 pub fn instance_get_active_for_block(
2323 &self,
2324 block_id: &str,
2325 ) -> Result<Option<AgentInstance>, StoreError> {
2326 let conn = self.conn.lock().unwrap();
2327 let mut stmt = conn.prepare(
2328 "SELECT id, definition_id, parent_instance_id, block_id, session_id,
2329 status, github_context, started_at, ended_at, created_at,
2330 identity_id, memory_id, instance_name, working_directory,
2331 display_hidden
2332 FROM db_agent_instances
2333 WHERE block_id = ?1 AND status IN ('running', 'paused')
2334 ORDER BY created_at DESC
2335 LIMIT 1",
2336 )?;
2337 let result = stmt.query_row(params![block_id], map_instance_row);
2338 match result {
2339 Ok(a) => Ok(Some(a)),
2340 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
2341 Err(e) => Err(e.into()),
2342 }
2343 }
2344
2345 pub(crate) fn agents_dual_write_definition_upsert(&self, def: &AgentDefinition) -> Result<(), StoreError> {
2373 let conn = self.conn.lock().unwrap();
2374 let is_template = if def.is_seeded == 1 { 1_i64 } else { 0_i64 };
2375 let parent_template_id = if def.is_seeded == 1 {
2376 String::new()
2377 } else {
2378 def.parent_id.clone()
2379 };
2380 conn.execute(
2389 "INSERT INTO db_agents (
2390 id, name, icon, description,
2391 is_template, parent_template_id,
2392 provider, provider_flags, shell, environment,
2393 agent_type, agent_bus_id, accounts,
2394 auto_start, restart_on_crash, idle_timeout_minutes,
2395 slug, branch_label,
2396 created_at, updated_at, is_seeded, user_hidden
2397 ) VALUES (
2398 ?1, ?2, ?3, ?4,
2399 ?5, ?6,
2400 ?7, ?8, ?9, ?10,
2401 ?11, ?12, ?13,
2402 ?14, ?15, ?16,
2403 ?17, ?18,
2404 ?19, ?20, ?21, ?22
2405 )
2406 ON CONFLICT(id) DO UPDATE SET
2407 name = excluded.name,
2408 icon = excluded.icon,
2409 description = excluded.description,
2410 is_template = excluded.is_template,
2411 parent_template_id = excluded.parent_template_id,
2412 provider = excluded.provider,
2413 provider_flags = excluded.provider_flags,
2414 shell = excluded.shell,
2415 environment = excluded.environment,
2416 agent_type = excluded.agent_type,
2417 agent_bus_id = excluded.agent_bus_id,
2418 accounts = excluded.accounts,
2419 auto_start = excluded.auto_start,
2420 restart_on_crash = excluded.restart_on_crash,
2421 idle_timeout_minutes = excluded.idle_timeout_minutes,
2422 slug = excluded.slug,
2423 branch_label = excluded.branch_label,
2424 updated_at = excluded.updated_at,
2425 is_seeded = excluded.is_seeded",
2426 params![
2427 def.id,
2428 def.name,
2429 def.icon,
2430 def.description,
2431 is_template,
2432 parent_template_id,
2433 def.provider,
2434 def.provider_flags,
2435 def.shell,
2436 def.environment,
2437 def.agent_type,
2438 def.agent_bus_id,
2439 def.accounts,
2440 def.auto_start,
2441 def.restart_on_crash,
2442 def.idle_timeout_minutes,
2443 def.slug,
2444 def.branch_label,
2445 def.created_at,
2446 def.updated_at,
2447 def.is_seeded,
2448 def.user_hidden,
2449 ],
2450 )?;
2451 Ok(())
2452 }
2453
2454 pub(crate) fn agents_dual_write_definition_delete(&self, def_id: &str) -> Result<(), StoreError> {
2460 let conn = self.conn.lock().unwrap();
2461 conn.execute(
2468 "DELETE FROM db_agents WHERE id = ?1",
2469 params![def_id],
2470 )?;
2471 Ok(())
2472 }
2473
2474 pub(crate) fn agents_dual_write_seeded_delete(&self, cascaded_inst_ids: &[String]) -> Result<(), StoreError> {
2488 let conn = self.conn.lock().unwrap();
2489 conn.execute(
2490 "DELETE FROM db_agents WHERE is_template = 1",
2491 [],
2492 )?;
2493 for inst_id in cascaded_inst_ids {
2498 conn.execute(
2499 "DELETE FROM db_agents WHERE id = ?1 AND is_template = 0",
2500 params![inst_id],
2501 )?;
2502 }
2503 Ok(())
2504 }
2505
2506 pub(crate) fn agents_dual_write_instance_create(&self, inst: &AgentInstance) -> Result<(), StoreError> {
2517 if !inst.parent_instance_id.is_empty() {
2518 return Ok(());
2519 }
2520 let conn = self.conn.lock().unwrap();
2521 let def = match Self::load_definition_for_dual_write(&conn, &inst.definition_id)? {
2523 Some(d) => d,
2524 None => {
2525 tracing::error!(
2532 instance_id = %inst.id,
2533 definition_id = %inst.definition_id,
2534 "db_agents dual-write: instance has no matching definition; skipping mirror",
2535 );
2536 return Ok(());
2537 }
2538 };
2539 let name = if inst.instance_name.is_empty() {
2540 def.name.clone()
2541 } else {
2542 inst.instance_name.clone()
2543 };
2544 let res = if def.is_seeded == 0 {
2554 let wall_now: i64 = std::time::SystemTime::now()
2573 .duration_since(std::time::UNIX_EPOCH)
2574 .map(|d| d.as_millis() as i64)
2575 .unwrap_or(inst.created_at);
2576 let global_prior: i64 = conn
2577 .query_row(
2578 "SELECT COALESCE(MAX(updated_at), 0) FROM db_agents",
2579 [],
2580 |row| row.get::<_, i64>(0),
2581 )
2582 .unwrap_or(0);
2583 let now_ms = std::cmp::max(wall_now, global_prior.saturating_add(1));
2584 conn.execute(
2585 "UPDATE db_agents SET
2586 name = ?2,
2587 identity_id = ?3,
2588 memory_id = ?4,
2589 working_directory = ?5,
2590 github_context = ?6,
2591 instance_name = ?7,
2592 updated_at = ?8,
2593 user_hidden = ?9
2594 WHERE id = ?1",
2595 params![
2596 def.id,
2597 name,
2598 inst.identity_id,
2599 inst.memory_id,
2600 inst.working_directory,
2601 inst.github_context,
2602 inst.instance_name,
2603 now_ms,
2604 if inst.display_hidden { 1_i64 } else { 0_i64 },
2605 ],
2606 )
2607 } else {
2608 conn.execute(
2609 "INSERT INTO db_agents (
2610 id, name, icon, description,
2611 is_template, parent_template_id,
2612 provider, provider_flags, shell, environment,
2613 agent_type, agent_bus_id, accounts,
2614 auto_start, restart_on_crash, idle_timeout_minutes,
2615 slug, branch_label,
2616 identity_id, memory_id, working_directory, github_context,
2617 instance_name,
2618 created_at, updated_at, is_seeded, user_hidden
2619 ) VALUES (
2620 ?1, ?2, ?3, ?4,
2621 0, ?5,
2622 ?6, ?7, ?8, ?9,
2623 ?10, ?11, ?12,
2624 ?13, ?14, ?15,
2625 ?16, ?17,
2626 ?18, ?19, ?20, ?21,
2627 ?22,
2628 ?23, ?24, 0, ?25
2629 )
2630 ON CONFLICT(id) DO UPDATE SET
2631 name = excluded.name,
2632 identity_id = excluded.identity_id,
2633 memory_id = excluded.memory_id,
2634 working_directory = excluded.working_directory,
2635 github_context = excluded.github_context,
2636 instance_name = excluded.instance_name,
2637 updated_at = excluded.updated_at,
2638 user_hidden = excluded.user_hidden",
2639 params![
2640 inst.id,
2641 name,
2642 def.icon,
2643 def.description,
2644 def.id, def.provider,
2646 def.provider_flags,
2647 def.shell,
2648 def.environment,
2649 def.agent_type,
2650 def.agent_bus_id,
2651 def.accounts,
2652 def.auto_start,
2653 def.restart_on_crash,
2654 def.idle_timeout_minutes,
2655 def.slug,
2656 def.branch_label,
2657 inst.identity_id,
2658 inst.memory_id,
2659 inst.working_directory,
2660 inst.github_context,
2661 inst.instance_name,
2662 inst.created_at,
2663 inst.created_at, if inst.display_hidden { 1_i64 } else { 0_i64 },
2665 ],
2666 )
2667 };
2668 res?;
2669 Ok(())
2670 }
2671
2672 pub(crate) fn agents_dual_write_instance_update(&self, inst: &AgentInstance) -> Result<(), StoreError> {
2677 if !inst.parent_instance_id.is_empty() {
2678 return Ok(());
2679 }
2680 let conn = self.conn.lock().unwrap();
2681 let now = std::time::SystemTime::now()
2682 .duration_since(std::time::UNIX_EPOCH)
2683 .unwrap_or_default()
2684 .as_millis() as i64;
2685 let key = match Self::agents_projection_key_for_inst(&conn, &inst.id) {
2691 Some((k, _)) => k,
2692 None => return Ok(()),
2693 };
2694 let global_prior: i64 = conn
2705 .query_row(
2706 "SELECT COALESCE(MAX(updated_at), 0) FROM db_agents",
2707 [],
2708 |row| row.get::<_, i64>(0),
2709 )
2710 .unwrap_or(0);
2711 let now_monotonic = std::cmp::max(now, global_prior.saturating_add(1));
2712 conn.execute(
2713 "UPDATE db_agents SET
2714 github_context = ?1,
2715 updated_at = ?2
2716 WHERE id = ?3 AND is_template = 0",
2717 params![inst.github_context, now_monotonic, key],
2718 )?;
2719 Ok(())
2720 }
2721
2722 pub(crate) fn agents_dual_write_instance_set_hidden(&self, id: &str, hidden: bool) -> Result<(), StoreError> {
2724 let conn = self.conn.lock().unwrap();
2725 let key = match Self::agents_projection_key_for_inst(&conn, id) {
2728 Some((k, _)) => k,
2729 None => return Ok(()),
2730 };
2731 conn.execute(
2732 "UPDATE db_agents SET user_hidden = ?1 WHERE id = ?2 AND is_template = 0",
2733 params![if hidden { 1_i64 } else { 0_i64 }, key],
2734 )?;
2735 Ok(())
2736 }
2737
2738 pub(crate) fn agents_dual_write_instance_repoint(
2742 &self,
2743 old_def_id: &str,
2744 new_def_id: &str,
2745 ) -> Result<(), StoreError> {
2746 let conn = self.conn.lock().unwrap();
2747 conn.execute(
2748 "UPDATE db_agents
2759 SET parent_template_id = ?1
2760 WHERE is_template = 0
2761 AND parent_template_id = ?2
2762 AND id IN (SELECT id FROM db_agent_instances WHERE definition_id = ?1)",
2763 params![new_def_id, old_def_id],
2764 )?;
2765 Ok(())
2766 }
2767
2768 pub(crate) fn agents_dual_write_instance_delete(&self, id: &str) -> Result<(), StoreError> {
2770 let conn = self.conn.lock().unwrap();
2771 let key_info = Self::agents_projection_key_for_inst(&conn, id);
2787 let (key, is_folded) = match key_info {
2788 Some(info) => info,
2789 None => (id.to_string(), false),
2790 };
2791 if is_folded {
2792 return Ok(());
2793 }
2794 conn.execute(
2795 "DELETE FROM db_agents WHERE id = ?1 AND is_template = 0",
2796 params![key],
2797 )?;
2798 Ok(())
2799 }
2800
2801 pub(crate) fn agents_dual_write_backfill_identity(&self, new_identity_id: &str) -> Result<(), StoreError> {
2805 let conn = self.conn.lock().unwrap();
2806 conn.execute(
2807 "UPDATE db_agents
2808 SET identity_id = ?1
2809 WHERE is_template = 0 AND (identity_id = '' OR identity_id = 'blank')",
2810 params![new_identity_id],
2811 )?;
2812 Ok(())
2813 }
2814
2815 fn agents_projection_key_for_inst(
2826 conn: &Connection,
2827 inst_id: &str,
2828 ) -> Option<(String, bool)> {
2829 conn.query_row(
2830 "SELECT i.id, i.definition_id, COALESCE(d.is_seeded, 1) AS is_seeded
2831 FROM db_agent_instances i
2832 LEFT JOIN db_agent_definitions d ON i.definition_id = d.id
2833 WHERE i.id = ?1",
2834 params![inst_id],
2835 |row| {
2836 let id: String = row.get(0)?;
2837 let def_id: String = row.get(1)?;
2838 let is_seeded: i64 = row.get(2)?;
2839 Ok(if is_seeded == 0 {
2840 (def_id, true) } else {
2842 (id, false) })
2844 },
2845 ).ok()
2846 }
2847
2848 fn load_definition_for_dual_write(
2852 conn: &Connection,
2853 id: &str,
2854 ) -> rusqlite::Result<Option<AgentDefinition>> {
2855 let mut stmt = conn.prepare(
2856 "SELECT id, slug, name, icon, provider, description,
2857 working_directory, shell, provider_flags, auto_start,
2858 restart_on_crash, idle_timeout_minutes, created_at,
2859 agent_type, environment, agent_bus_id, is_seeded,
2860 accounts, parent_id, branch_label, updated_at,
2861 user_hidden
2862 FROM db_agent_definitions WHERE id = ?1",
2863 )?;
2864 let result = stmt.query_row(params![id], |row| {
2865 Ok(AgentDefinition {
2866 id: row.get(0)?,
2867 slug: row.get(1)?,
2868 name: row.get(2)?,
2869 icon: row.get(3)?,
2870 provider: row.get(4)?,
2871 description: row.get(5)?,
2872 working_directory: row.get(6)?,
2873 shell: row.get(7)?,
2874 provider_flags: row.get(8)?,
2875 auto_start: row.get(9)?,
2876 restart_on_crash: row.get(10)?,
2877 idle_timeout_minutes: row.get(11)?,
2878 created_at: row.get(12)?,
2879 agent_type: row.get(13)?,
2880 environment: row.get(14)?,
2881 agent_bus_id: row.get(15)?,
2882 is_seeded: row.get(16)?,
2883 accounts: row.get(17)?,
2884 parent_id: row.get(18)?,
2885 branch_label: row.get(19)?,
2886 updated_at: row.get(20)?,
2887 user_hidden: row.get(21)?,
2888 })
2889 });
2890 match result {
2891 Ok(d) => Ok(Some(d)),
2892 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
2893 Err(e) => Err(e),
2894 }
2895 }
2896
2897 pub fn bundle_identity_list(&self) -> Result<Vec<Identity>, StoreError> {
2906 let conn = self.conn.lock().unwrap();
2907 let mut stmt = conn.prepare(
2908 "SELECT id, name, description, is_blank, created_at, updated_at
2909 FROM db_identity_bundles
2910 ORDER BY is_blank ASC, updated_at DESC",
2911 )?;
2912 let iter = stmt.query_map([], |row| {
2913 Ok(Identity {
2914 id: row.get(0)?,
2915 name: row.get(1)?,
2916 description: row.get(2)?,
2917 is_blank: row.get::<_, i64>(3)? != 0,
2918 created_at: row.get(4)?,
2919 updated_at: row.get(5)?,
2920 })
2921 })?;
2922 let mut out = Vec::new();
2923 for r in iter {
2924 out.push(r?);
2925 }
2926 Ok(out)
2927 }
2928
2929 pub fn bundle_identity_get(&self, id: &str) -> Result<Option<Identity>, StoreError> {
2930 let conn = self.conn.lock().unwrap();
2931 let mut stmt = conn.prepare(
2932 "SELECT id, name, description, is_blank, created_at, updated_at
2933 FROM db_identity_bundles WHERE id = ?1",
2934 )?;
2935 let result = stmt.query_row(params![id], |row| {
2936 Ok(Identity {
2937 id: row.get(0)?,
2938 name: row.get(1)?,
2939 description: row.get(2)?,
2940 is_blank: row.get::<_, i64>(3)? != 0,
2941 created_at: row.get(4)?,
2942 updated_at: row.get(5)?,
2943 })
2944 });
2945 match result {
2946 Ok(i) => Ok(Some(i)),
2947 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
2948 Err(e) => Err(e.into()),
2949 }
2950 }
2951
2952 pub fn bundle_identity_upsert(&self, identity: &Identity) -> Result<(), StoreError> {
2956 let conn = self.conn.lock().unwrap();
2957 conn.execute(
2958 "INSERT INTO db_identity_bundles
2959 (id, name, description, is_blank, created_at, updated_at)
2960 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
2961 ON CONFLICT(id) DO UPDATE SET
2962 name = excluded.name,
2963 description = excluded.description,
2964 updated_at = excluded.updated_at",
2965 params![
2966 identity.id,
2967 identity.name,
2968 identity.description,
2969 identity.is_blank as i64,
2970 identity.created_at,
2971 identity.updated_at,
2972 ],
2973 )?;
2974 Ok(())
2975 }
2976
2977 pub fn bundle_identity_delete(&self, id: &str) -> Result<bool, StoreError> {
2980 if id == "blank" {
2981 return Err(StoreError::Other(
2982 "cannot delete the blank Identity singleton".to_string(),
2983 ));
2984 }
2985 let conn = self.conn.lock().unwrap();
2986 let rows = conn.execute("DELETE FROM db_identity_bundles WHERE id = ?1", params![id])?;
2987 Ok(rows > 0)
2988 }
2989
2990 pub fn bundle_identity_bind(
2995 &self,
2996 identity_id: &str,
2997 provider: &str,
2998 account_id: &str,
2999 ) -> Result<(), StoreError> {
3000 let conn = self.conn.lock().unwrap();
3001 conn.execute(
3002 "INSERT INTO db_identity_bindings (identity_id, provider, account_id)
3003 VALUES (?1, ?2, ?3)
3004 ON CONFLICT(identity_id, provider) DO UPDATE SET account_id = excluded.account_id",
3005 params![identity_id, provider, account_id],
3006 )?;
3007 Ok(())
3008 }
3009
3010 pub fn bundle_identity_unbind(
3013 &self,
3014 identity_id: &str,
3015 provider: &str,
3016 ) -> Result<bool, StoreError> {
3017 let conn = self.conn.lock().unwrap();
3018 let rows = conn.execute(
3019 "DELETE FROM db_identity_bindings WHERE identity_id = ?1 AND provider = ?2",
3020 params![identity_id, provider],
3021 )?;
3022 Ok(rows > 0)
3023 }
3024
3025 pub fn bundle_identity_bindings(
3027 &self,
3028 identity_id: &str,
3029 ) -> Result<Vec<IdentityBinding>, StoreError> {
3030 let conn = self.conn.lock().unwrap();
3031 let mut stmt = conn.prepare(
3032 "SELECT identity_id, provider, account_id
3033 FROM db_identity_bindings
3034 WHERE identity_id = ?1
3035 ORDER BY provider ASC",
3036 )?;
3037 let iter = stmt.query_map(params![identity_id], |row| {
3038 Ok(IdentityBinding {
3039 identity_id: row.get(0)?,
3040 provider: row.get(1)?,
3041 account_id: row.get(2)?,
3042 })
3043 })?;
3044 let mut out = Vec::new();
3045 for r in iter {
3046 out.push(r?);
3047 }
3048 Ok(out)
3049 }
3050
3051 pub fn bundle_memory_list(&self) -> Result<Vec<Memory>, StoreError> {
3054 let conn = self.conn.lock().unwrap();
3055 let mut stmt = conn.prepare(
3056 "SELECT id, name, description, is_blank, provider, model, instructions,
3057 context_files, mcp_servers, skills, created_at, updated_at
3058 FROM db_memory_bundles
3059 ORDER BY is_blank ASC, updated_at DESC",
3060 )?;
3061 let iter = stmt.query_map([], map_memory_row)?;
3062 let mut out = Vec::new();
3063 for r in iter {
3064 out.push(r?);
3065 }
3066 Ok(out)
3067 }
3068
3069 pub fn bundle_memory_get(&self, id: &str) -> Result<Option<Memory>, StoreError> {
3070 let conn = self.conn.lock().unwrap();
3071 let mut stmt = conn.prepare(
3072 "SELECT id, name, description, is_blank, provider, model, instructions,
3073 context_files, mcp_servers, skills, created_at, updated_at
3074 FROM db_memory_bundles WHERE id = ?1",
3075 )?;
3076 let result = stmt.query_row(params![id], map_memory_row);
3077 match result {
3078 Ok(m) => Ok(Some(m)),
3079 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
3080 Err(e) => Err(e.into()),
3081 }
3082 }
3083
3084 pub fn bundle_memory_upsert(&self, memory: &Memory) -> Result<(), StoreError> {
3085 let conn = self.conn.lock().unwrap();
3086 conn.execute(
3087 "INSERT INTO db_memory_bundles
3088 (id, name, description, is_blank, provider, model, instructions,
3089 context_files, mcp_servers, skills, created_at, updated_at)
3090 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
3091 ON CONFLICT(id) DO UPDATE SET
3092 name = excluded.name,
3093 description = excluded.description,
3094 provider = excluded.provider,
3095 model = excluded.model,
3096 instructions = excluded.instructions,
3097 context_files = excluded.context_files,
3098 mcp_servers = excluded.mcp_servers,
3099 skills = excluded.skills,
3100 updated_at = excluded.updated_at",
3101 params![
3102 memory.id,
3103 memory.name,
3104 memory.description,
3105 memory.is_blank as i64,
3106 memory.provider,
3107 memory.model,
3108 memory.instructions,
3109 memory.context_files,
3110 memory.mcp_servers,
3111 memory.skills,
3112 memory.created_at,
3113 memory.updated_at,
3114 ],
3115 )?;
3116 Ok(())
3117 }
3118
3119 pub fn bundle_memory_delete(&self, id: &str) -> Result<bool, StoreError> {
3121 if id == "blank" {
3122 return Err(StoreError::Other(
3123 "cannot delete the blank Memory singleton".to_string(),
3124 ));
3125 }
3126 let conn = self.conn.lock().unwrap();
3127 let rows = conn.execute("DELETE FROM db_memory_bundles WHERE id = ?1", params![id])?;
3128 Ok(rows > 0)
3129 }
3130}
3131
3132fn map_memory_row(row: &rusqlite::Row) -> rusqlite::Result<Memory> {
3133 Ok(Memory {
3134 id: row.get(0)?,
3135 name: row.get(1)?,
3136 description: row.get(2)?,
3137 is_blank: row.get::<_, i64>(3)? != 0,
3138 provider: row.get(4)?,
3139 model: row.get(5)?,
3140 instructions: row.get(6)?,
3141 context_files: row.get(7)?,
3142 mcp_servers: row.get(8)?,
3143 skills: row.get(9)?,
3144 created_at: row.get(10)?,
3145 updated_at: row.get(11)?,
3146 })
3147}
3148
3149fn map_instance_row(row: &rusqlite::Row) -> rusqlite::Result<AgentInstance> {
3150 let display_hidden_int: i64 = row.get(14)?;
3151 Ok(AgentInstance {
3152 id: row.get(0)?,
3153 definition_id: row.get(1)?,
3154 parent_instance_id: row.get(2)?,
3155 block_id: row.get(3)?,
3156 session_id: row.get(4)?,
3157 status: row.get(5)?,
3158 github_context: row.get(6)?,
3159 started_at: row.get(7)?,
3160 ended_at: row.get(8)?,
3161 created_at: row.get(9)?,
3162 identity_id: row.get(10)?,
3163 memory_id: row.get(11)?,
3164 instance_name: row.get(12)?,
3165 working_directory: row.get(13)?,
3166 display_hidden: display_hidden_int != 0,
3167 })
3168}
3169
3170fn map_agent_definition_row(row: &rusqlite::Row) -> rusqlite::Result<AgentDefinition> {
3176 Ok(AgentDefinition {
3177 id: row.get(0)?,
3178 slug: row.get(1)?,
3179 name: row.get(2)?,
3180 icon: row.get(3)?,
3181 provider: row.get(4)?,
3182 description: row.get(5)?,
3183 working_directory: row.get(6)?,
3184 shell: row.get(7)?,
3185 provider_flags: row.get(8)?,
3186 auto_start: row.get(9)?,
3187 restart_on_crash: row.get(10)?,
3188 idle_timeout_minutes: row.get(11)?,
3189 created_at: row.get(12)?,
3190 agent_type: row.get(13)?,
3191 environment: row.get(14)?,
3192 agent_bus_id: row.get(15)?,
3193 is_seeded: row.get(16)?,
3194 accounts: row.get(17)?,
3195 parent_id: row.get(18)?,
3196 branch_label: row.get(19)?,
3197 updated_at: row.get(20)?,
3198 user_hidden: row.get(21)?,
3199 })
3200}
3201
3202#[cfg(test)]
3207mod tests {
3208 use super::*;
3209 use crate::backend::obj::*;
3210
3211 fn make_store() -> WaveStore {
3212 WaveStore::open_in_memory().unwrap()
3213 }
3214
3215 #[test]
3216 fn test_insert_and_get_client() {
3217 let store = make_store();
3218 let mut client = Client {
3219 oid: "test-client-oid".to_string(),
3220 version: 0,
3221 windowids: vec!["w1".to_string()],
3222 meta: MetaMapType::new(),
3223 tosagreed: 1700000000000,
3224 ..Default::default()
3225 };
3226 store.insert(&mut client).unwrap();
3227 assert_eq!(client.get_version(), 1);
3228
3229 let loaded = store.must_get::<Client>("test-client-oid").unwrap();
3230 assert_eq!(loaded.oid, "test-client-oid");
3231 assert_eq!(loaded.version, 1);
3232 assert_eq!(loaded.windowids, vec!["w1"]);
3233 assert_eq!(loaded.tosagreed, 1700000000000);
3234 }
3235
3236 #[test]
3237 fn test_insert_and_get_window() {
3238 let store = make_store();
3239 let mut win = Window {
3240 oid: "win-1".to_string(),
3241 workspaceid: "ws-1".to_string(),
3242 pos: Point { x: 10, y: 20 },
3243 winsize: WinSize {
3244 width: 800,
3245 height: 600,
3246 },
3247 meta: MetaMapType::new(),
3248 ..Default::default()
3249 };
3250 store.insert(&mut win).unwrap();
3251
3252 let loaded = store.must_get::<Window>("win-1").unwrap();
3253 assert_eq!(loaded.workspaceid, "ws-1");
3254 assert_eq!(loaded.pos.x, 10);
3255 assert_eq!(loaded.winsize.width, 800);
3256 }
3257
3258 #[test]
3259 fn test_insert_and_get_workspace() {
3260 let store = make_store();
3261 let mut ws = Workspace {
3262 oid: "ws-1".to_string(),
3263 name: "Test WS".to_string(),
3264 tabids: vec!["t1".to_string()],
3265 activetabid: "t1".to_string(),
3266 meta: MetaMapType::new(),
3267 ..Default::default()
3268 };
3269 store.insert(&mut ws).unwrap();
3270
3271 let loaded = store.must_get::<Workspace>("ws-1").unwrap();
3272 assert_eq!(loaded.name, "Test WS");
3273 assert_eq!(loaded.tabids, vec!["t1"]);
3274 }
3275
3276 #[test]
3277 fn test_insert_and_get_tab() {
3278 let store = make_store();
3279 let mut tab = Tab {
3280 oid: "tab-1".to_string(),
3281 name: "Shell".to_string(),
3282 layoutstate: "ls-1".to_string(),
3283 blockids: vec!["b1".to_string()],
3284 meta: MetaMapType::new(),
3285 ..Default::default()
3286 };
3287 store.insert(&mut tab).unwrap();
3288
3289 let loaded = store.must_get::<Tab>("tab-1").unwrap();
3290 assert_eq!(loaded.name, "Shell");
3291 }
3292
3293 #[test]
3294 fn test_insert_and_get_block() {
3295 let store = make_store();
3296 let mut block = Block {
3297 oid: "blk-1".to_string(),
3298 parentoref: "tab:tab-1".to_string(),
3299 meta: {
3300 let mut m = MetaMapType::new();
3301 m.insert("view".into(), serde_json::json!("term"));
3302 m
3303 },
3304 ..Default::default()
3305 };
3306 store.insert(&mut block).unwrap();
3307
3308 let loaded = store.must_get::<Block>("blk-1").unwrap();
3309 assert_eq!(loaded.parentoref, "tab:tab-1");
3310 assert_eq!(loaded.meta.get("view").unwrap(), "term");
3311 }
3312
3313 #[test]
3314 fn test_insert_and_get_layout_state() {
3315 let store = make_store();
3316 let mut ls = LayoutState {
3318 oid: "ls-1".to_string(),
3319 rootnode: Some(crate::backend::obj::LayoutNode {
3320 id: "n1".into(),
3321 flex_direction: crate::backend::obj::FlexDirection::Row,
3322 size: 1.0,
3323 children: Vec::new(),
3324 data: None,
3325 ..Default::default()
3326 }),
3327 magnifiednodeid: "n1".to_string(),
3328 ..Default::default()
3329 };
3330 store.insert(&mut ls).unwrap();
3331
3332 let loaded = store.must_get::<LayoutState>("ls-1").unwrap();
3333 assert_eq!(loaded.magnifiednodeid, "n1");
3334 assert!(loaded.rootnode.is_some());
3335 }
3336
3337 #[test]
3338 fn test_get_nonexistent_returns_none() {
3339 let store = make_store();
3340 let result = store.get::<Client>("nonexistent").unwrap();
3341 assert!(result.is_none());
3342 }
3343
3344 #[test]
3345 fn test_must_get_nonexistent_returns_error() {
3346 let store = make_store();
3347 let result = store.must_get::<Client>("nonexistent");
3348 assert!(matches!(result, Err(StoreError::NotFound)));
3349 }
3350
3351 #[test]
3352 fn test_update_increments_version() {
3353 let store = make_store();
3354 let mut client = Client {
3355 oid: "c1".to_string(),
3356 meta: MetaMapType::new(),
3357 ..Default::default()
3358 };
3359 store.insert(&mut client).unwrap();
3360 assert_eq!(client.version, 1);
3361
3362 client.windowids = vec!["w-new".to_string()];
3363 let v2 = store.update(&mut client).unwrap();
3364 assert_eq!(v2, 2);
3365 assert_eq!(client.version, 2);
3366
3367 let v3 = store.update(&mut client).unwrap();
3368 assert_eq!(v3, 3);
3369 }
3370
3371 #[test]
3372 fn test_delete() {
3373 let store = make_store();
3374 let mut client = Client {
3375 oid: "del-me".to_string(),
3376 meta: MetaMapType::new(),
3377 ..Default::default()
3378 };
3379 store.insert(&mut client).unwrap();
3380 assert!(store.get::<Client>("del-me").unwrap().is_some());
3381
3382 store.delete::<Client>("del-me").unwrap();
3383 assert!(store.get::<Client>("del-me").unwrap().is_none());
3384 }
3385
3386 #[test]
3387 fn test_get_all() {
3388 let store = make_store();
3389 for i in 0..3 {
3390 let mut tab = Tab {
3391 oid: format!("tab-{i}"),
3392 name: format!("Tab {i}"),
3393 meta: MetaMapType::new(),
3394 ..Default::default()
3395 };
3396 store.insert(&mut tab).unwrap();
3397 }
3398
3399 let all = store.get_all::<Tab>().unwrap();
3400 assert_eq!(all.len(), 3);
3401 }
3402
3403 #[test]
3404 fn test_count() {
3405 let store = make_store();
3406 assert_eq!(store.count::<Client>().unwrap(), 0);
3407
3408 let mut c = Client {
3409 oid: "c1".to_string(),
3410 meta: MetaMapType::new(),
3411 ..Default::default()
3412 };
3413 store.insert(&mut c).unwrap();
3414 assert_eq!(store.count::<Client>().unwrap(), 1);
3415 }
3416
3417 #[test]
3418 fn test_insert_empty_oid_fails() {
3419 let store = make_store();
3420 let mut client = Client {
3421 oid: String::new(),
3422 meta: MetaMapType::new(),
3423 ..Default::default()
3424 };
3425 let result = store.insert(&mut client);
3426 assert!(matches!(result, Err(StoreError::EmptyOID)));
3427 }
3428
3429 #[test]
3430 fn test_with_tx_commits_on_success() {
3431 let store = make_store();
3432 store
3433 .with_tx(|tx| {
3434 let mut ws = Workspace {
3435 oid: "ws-tx".to_string(),
3436 name: "TX Workspace".to_string(),
3437 meta: MetaMapType::new(),
3438 ..Default::default()
3439 };
3440 tx.insert(&mut ws)?;
3441
3442 let mut tab = Tab {
3443 oid: "tab-tx".to_string(),
3444 name: "tab1".to_string(),
3446 layoutstate: "ls-tx".to_string(),
3447 meta: MetaMapType::new(),
3448 ..Default::default()
3449 };
3450 tx.insert(&mut tab)?;
3451
3452 ws.tabids.push("tab-tx".to_string());
3454 tx.update(&mut ws)?;
3455
3456 Ok(())
3457 })
3458 .unwrap();
3459
3460 let ws = store.must_get::<Workspace>("ws-tx").unwrap();
3462 assert_eq!(ws.name, "TX Workspace");
3463 assert_eq!(ws.tabids, vec!["tab-tx"]);
3464 assert_eq!(ws.version, 2); let tab = store.must_get::<Tab>("tab-tx").unwrap();
3467 assert_eq!(tab.name, "tab1");
3468 }
3469
3470 #[test]
3471 fn test_with_tx_rollbacks_on_error() {
3472 let store = make_store();
3473 let result: Result<(), StoreError> = store.with_tx(|tx| {
3474 let mut ws = Workspace {
3475 oid: "ws-rollback".to_string(),
3476 name: "Should Not Exist".to_string(),
3477 meta: MetaMapType::new(),
3478 ..Default::default()
3479 };
3480 tx.insert(&mut ws)?;
3481
3482 Err(StoreError::Other("intentional failure".to_string()))
3484 });
3485 assert!(result.is_err());
3486
3487 let ws = store.get::<Workspace>("ws-rollback").unwrap();
3489 assert!(ws.is_none());
3490 }
3491
3492 #[test]
3493 fn test_agent_def_insert_collision_resolves_at_runtime() {
3494 let store = make_store();
3500
3501 let mut a1 = AgentDefinition {
3502 id: "id-a".to_string(),
3503 slug: String::new(),
3504 name: "Agent X".to_string(),
3505 icon: "✦".to_string(),
3506 provider: "claude".to_string(),
3507 description: String::new(),
3508 working_directory: String::new(),
3509 shell: String::new(),
3510 provider_flags: String::new(),
3511 auto_start: 0,
3512 restart_on_crash: 0,
3513 idle_timeout_minutes: 0,
3514 created_at: 0,
3515 agent_type: "host".to_string(),
3516 environment: String::new(),
3517 agent_bus_id: String::new(),
3518 is_seeded: 0,
3519 accounts: String::new(),
3520 parent_id: String::new(),
3521 branch_label: String::new(),
3522 updated_at: 0,
3523 user_hidden: 0,
3524 };
3525 store.agent_def_insert(&mut a1).unwrap();
3526 assert_eq!(a1.slug, "agent-x");
3528
3529 let mut a2 = AgentDefinition {
3530 id: "id-b".to_string(),
3531 name: "agent x".to_string(),
3533 ..a1.clone()
3534 };
3535 a2.slug = String::new();
3536 store.agent_def_insert(&mut a2).unwrap();
3537 assert_eq!(a2.slug, "agent-x-2");
3538
3539 let mut a3 = AgentDefinition {
3540 id: "id-c".to_string(),
3541 name: "AGENT-X".to_string(),
3542 ..a1.clone()
3543 };
3544 a3.slug = String::new();
3545 store.agent_def_insert(&mut a3).unwrap();
3546 assert_eq!(a3.slug, "agent-x-3");
3547
3548 let listed = store.agent_def_list().unwrap();
3550 let slugs: Vec<&str> = listed.iter().map(|a| a.slug.as_str()).collect();
3551 assert!(slugs.contains(&"agent-x"));
3552 assert!(slugs.contains(&"agent-x-2"));
3553 assert!(slugs.contains(&"agent-x-3"));
3554 }
3555
3556 #[test]
3557 fn test_agent_def_insert_explicit_slug_collision_resolves() {
3558 let store = make_store();
3563
3564 let mut a1 = AgentDefinition {
3565 id: "id-a".to_string(),
3566 slug: "explicit".to_string(),
3567 name: "First".to_string(),
3568 icon: "✦".to_string(),
3569 provider: "claude".to_string(),
3570 description: String::new(),
3571 working_directory: String::new(),
3572 shell: String::new(),
3573 provider_flags: String::new(),
3574 auto_start: 0,
3575 restart_on_crash: 0,
3576 idle_timeout_minutes: 0,
3577 created_at: 0,
3578 agent_type: "host".to_string(),
3579 environment: String::new(),
3580 agent_bus_id: String::new(),
3581 is_seeded: 0,
3582 accounts: String::new(),
3583 parent_id: String::new(),
3584 branch_label: String::new(),
3585 updated_at: 0,
3586 user_hidden: 0,
3587 };
3588 store.agent_def_insert(&mut a1).unwrap();
3589 assert_eq!(a1.slug, "explicit");
3590
3591 let mut a2 = AgentDefinition {
3592 id: "id-b".to_string(),
3593 ..a1.clone()
3594 };
3595 a2.slug = "explicit".to_string();
3596 store.agent_def_insert(&mut a2).unwrap();
3597 assert_eq!(a2.slug, "explicit-2");
3598 }
3599
3600 fn v6_test_store() -> WaveStore {
3603 WaveStore::open_in_memory().unwrap()
3604 }
3605
3606 fn sample_account(id: &str, provider: &str) -> IdentityAccount {
3607 IdentityAccount {
3608 id: id.to_string(),
3609 name: format!("asaf-{provider}"),
3610 provider: provider.to_string(),
3611 kind: "pat".to_string(),
3612 display_name: "".to_string(),
3613 secret_ref: SecretRef::Env { env_var: format!("{}_TOKEN", provider.to_uppercase()) },
3614 context: serde_json::json!({"username": "asaf"}),
3615 status: "unknown".to_string(),
3616 created_at: 0,
3617 updated_at: 0,
3618 }
3619 }
3620
3621 fn sample_agent(id: &str, slug: &str) -> AgentDefinition {
3622 AgentDefinition {
3623 id: id.to_string(),
3624 slug: slug.to_string(),
3625 name: id.to_string(),
3626 icon: "✦".to_string(),
3627 provider: "claude".to_string(),
3628 description: "".to_string(),
3629 working_directory: "".to_string(),
3630 shell: "".to_string(),
3631 provider_flags: "".to_string(),
3632 auto_start: 0,
3633 restart_on_crash: 0,
3634 idle_timeout_minutes: 0,
3635 created_at: 0,
3636 agent_type: "host".to_string(),
3637 environment: "".to_string(),
3638 agent_bus_id: "".to_string(),
3639 is_seeded: 0,
3640 accounts: String::new(),
3641 parent_id: String::new(),
3642 branch_label: String::new(),
3643 updated_at: 0,
3644 user_hidden: 0,
3645 }
3646 }
3647
3648 #[test]
3649 fn test_identity_upsert_round_trip() {
3650 let store = v6_test_store();
3651 let acct = sample_account("id-gh", "github");
3652 store.identity_upsert(&acct).unwrap();
3653
3654 let fetched = store.identity_get("id-gh").unwrap().expect("row");
3655 assert_eq!(fetched.name, "asaf-github");
3656 assert_eq!(fetched.provider, "github");
3657 assert!(matches!(fetched.secret_ref, SecretRef::Env { ref env_var } if env_var == "GITHUB_TOKEN"));
3658 assert_eq!(fetched.context["username"], "asaf");
3659 }
3660
3661 #[test]
3662 fn test_identity_list_filtered_by_provider() {
3663 let store = v6_test_store();
3664 store.identity_upsert(&sample_account("id-gh", "github")).unwrap();
3665 store.identity_upsert(&sample_account("id-aws", "aws")).unwrap();
3666 store.identity_upsert(&sample_account("id-gh2", "github")).unwrap();
3667
3668 let all = store.identity_list(None).unwrap();
3669 assert_eq!(all.len(), 3);
3670 let gh = store.identity_list(Some("github")).unwrap();
3671 assert_eq!(gh.len(), 2);
3672 assert!(gh.iter().all(|a| a.provider == "github"));
3673 }
3674
3675 #[test]
3676 fn test_identity_delete() {
3677 let store = v6_test_store();
3678 store.identity_upsert(&sample_account("id-gh", "github")).unwrap();
3679 assert!(store.identity_delete("id-gh").unwrap());
3680 assert!(store.identity_get("id-gh").unwrap().is_none());
3681 assert!(!store.identity_delete("id-gh").unwrap());
3683 }
3684
3685 #[test]
3686 fn test_agent_identity_link_and_unlink() {
3687 let store = v6_test_store();
3688 let mut agent = sample_agent("ag1", "agent-x");
3689 store.agent_def_insert(&mut agent).unwrap();
3690 store.identity_upsert(&sample_account("id-gh", "github")).unwrap();
3691
3692 store.agent_identity_link("ag1", "id-gh", "github").unwrap();
3693 let links = store.agent_identity_list_for_agent("ag1").unwrap();
3694 assert_eq!(links.len(), 1);
3695 assert_eq!(links[0].account_id, "id-gh");
3696
3697 store.identity_upsert(&sample_account("id-gh2", "github")).unwrap();
3699 store.agent_identity_link("ag1", "id-gh2", "github").unwrap();
3700 let links = store.agent_identity_list_for_agent("ag1").unwrap();
3701 assert_eq!(links.len(), 1);
3702 assert_eq!(links[0].account_id, "id-gh2");
3703
3704 assert!(store.agent_identity_unlink("ag1", "github").unwrap());
3705 assert!(store.agent_identity_list_for_agent("ag1").unwrap().is_empty());
3706 }
3707
3708 #[test]
3709 fn test_agent_identity_cascade_on_agent_delete() {
3710 let store = v6_test_store();
3711 let mut agent = sample_agent("ag1", "agent-x");
3712 store.agent_def_insert(&mut agent).unwrap();
3713 store.identity_upsert(&sample_account("id-gh", "github")).unwrap();
3714 store.agent_identity_link("ag1", "id-gh", "github").unwrap();
3715
3716 store.agent_def_delete("ag1").unwrap();
3717 assert!(store.agent_identity_list_for_agent("ag1").unwrap().is_empty());
3718 }
3719
3720 #[test]
3721 fn test_instance_create_update_filter() {
3722 let store = v6_test_store();
3723 let mut agent = sample_agent("def1", "agent-x");
3724 store.agent_def_insert(&mut agent).unwrap();
3725
3726 let inst = AgentInstance {
3727 id: "inst1".to_string(),
3728 definition_id: "def1".to_string(),
3729 parent_instance_id: String::new(),
3730 block_id: "block-abc".to_string(),
3731 session_id: String::new(),
3732 status: InstanceStatus::Running.as_str().to_string(),
3733 github_context: String::new(),
3734 started_at: 1000,
3735 ended_at: 0,
3736 created_at: 1000,
3737 identity_id: String::new(),
3738 memory_id: String::new(),
3739 instance_name: String::new(),
3740 working_directory: String::new(),
3741 display_hidden: false,
3742 };
3743 store.instance_create(&inst).unwrap();
3744
3745 let fetched = store.instance_get("inst1").unwrap().expect("row");
3746 assert_eq!(fetched.block_id, "block-abc");
3747 assert_eq!(fetched.status, "running");
3748
3749 let mut updated = fetched.clone();
3751 updated.status = InstanceStatus::Stopped.as_str().to_string();
3752 updated.ended_at = 2000;
3753 assert!(store.instance_update(&updated).unwrap());
3754 assert_eq!(store.instance_get("inst1").unwrap().unwrap().status, "stopped");
3755
3756 let all = store.instance_list(None, None).unwrap();
3758 assert_eq!(all.len(), 1);
3759 let by_def = store.instance_list(Some("def1"), None).unwrap();
3760 assert_eq!(by_def.len(), 1);
3761 let running = store.instance_list(None, Some("running")).unwrap();
3762 assert_eq!(running.len(), 0);
3763 let stopped = store.instance_list(None, Some("stopped")).unwrap();
3764 assert_eq!(stopped.len(), 1);
3765 }
3766
3767 #[test]
3768 fn test_agent_def_list_orders_by_last_used() {
3769 let store = v6_test_store();
3770 let mut a = sample_agent("def-a", "agent-a");
3772 let mut b = sample_agent("def-b", "agent-b");
3773 let mut c = sample_agent("def-c", "agent-c");
3774 store.agent_def_insert(&mut a).unwrap();
3775 store.agent_def_insert(&mut b).unwrap();
3776 store.agent_def_insert(&mut c).unwrap();
3777
3778 let mk = |id: &str, def: &str, started: i64| AgentInstance {
3779 id: id.to_string(),
3780 definition_id: def.to_string(),
3781 parent_instance_id: String::new(),
3782 block_id: String::new(),
3783 session_id: String::new(),
3784 status: InstanceStatus::Running.as_str().to_string(),
3785 github_context: String::new(),
3786 started_at: started,
3787 ended_at: 0,
3788 created_at: started,
3789 identity_id: String::new(),
3790 memory_id: String::new(),
3791 instance_name: String::new(),
3792 working_directory: String::new(),
3793 display_hidden: false,
3794 };
3795 store.instance_create(&mk("i-a", "def-a", 500)).unwrap();
3797 store.instance_create(&mk("i-b", "def-b", 600)).unwrap();
3798
3799 let ids = |s: &WaveStore| -> Vec<String> {
3800 s.agent_def_list().unwrap().into_iter().map(|d| d.id).collect()
3801 };
3802 assert_eq!(ids(&store), vec!["def-b", "def-a", "def-c"]);
3804
3805 store.instance_create(&mk("i-a2", "def-a", 700)).unwrap();
3807 assert_eq!(ids(&store), vec!["def-a", "def-b", "def-c"]);
3808 }
3809
3810 #[test]
3811 fn test_instance_cascade_on_definition_delete() {
3812 let store = v6_test_store();
3813 let mut agent = sample_agent("def1", "agent-x");
3814 store.agent_def_insert(&mut agent).unwrap();
3815 let inst = AgentInstance {
3816 id: "inst1".to_string(),
3817 definition_id: "def1".to_string(),
3818 parent_instance_id: String::new(),
3819 block_id: String::new(),
3820 session_id: String::new(),
3821 status: "running".to_string(),
3822 github_context: String::new(),
3823 started_at: 0,
3824 ended_at: 0,
3825 created_at: 0,
3826 identity_id: String::new(),
3827 memory_id: String::new(),
3828 instance_name: String::new(),
3829 working_directory: String::new(),
3830 display_hidden: false,
3831 };
3832 store.instance_create(&inst).unwrap();
3833
3834 store.agent_def_delete("def1").unwrap();
3835 assert!(store.instance_get("inst1").unwrap().is_none());
3836 }
3837
3838 #[test]
3839 fn test_instance_status_enum_roundtrip() {
3840 for s in &[
3841 InstanceStatus::Running,
3842 InstanceStatus::Paused,
3843 InstanceStatus::Stopped,
3844 InstanceStatus::Crashed,
3845 InstanceStatus::Detached,
3846 ] {
3847 assert_eq!(Some(*s), InstanceStatus::parse(s.as_str()));
3848 }
3849 assert_eq!(None, InstanceStatus::parse("nonsense"));
3850 }
3851
3852 #[test]
3855 fn test_bundle_identity_lifecycle() {
3856 let store = make_store();
3857
3858 let initial = store.bundle_identity_list().unwrap();
3860 assert_eq!(initial.len(), 1);
3861 assert!(initial[0].is_blank);
3862 assert_eq!(initial[0].id, "blank");
3863
3864 let work = Identity {
3866 id: "id-work".to_string(),
3867 name: "Work".to_string(),
3868 description: "Office laptop credentials".to_string(),
3869 is_blank: false,
3870 created_at: 100,
3871 updated_at: 100,
3872 };
3873 store.bundle_identity_upsert(&work).unwrap();
3874
3875 let listed = store.bundle_identity_list().unwrap();
3877 assert_eq!(listed.len(), 2);
3878 assert_eq!(listed[0].id, "id-work");
3879 assert_eq!(listed[1].id, "blank");
3880
3881 let fetched = store.bundle_identity_get("id-work").unwrap().unwrap();
3883 assert_eq!(fetched.name, "Work");
3884
3885 let blank_delete = store.bundle_identity_delete("blank");
3887 assert!(blank_delete.is_err());
3888
3889 assert!(store.bundle_identity_delete("id-work").unwrap());
3891 assert_eq!(store.bundle_identity_list().unwrap().len(), 1);
3892 }
3893
3894 #[test]
3895 fn test_bundle_identity_bindings_round_trip() {
3896 let store = make_store();
3897
3898 let acct = IdentityAccount {
3900 id: "acct-1".to_string(),
3901 name: "asaf-github".to_string(),
3902 provider: "github".to_string(),
3903 kind: "pat".to_string(),
3904 display_name: String::new(),
3905 secret_ref: SecretRef::Env {
3906 env_var: "GITHUB_TOKEN".to_string(),
3907 },
3908 context: serde_json::json!({}),
3909 status: "unknown".to_string(),
3910 created_at: 0,
3911 updated_at: 0,
3912 };
3913 store.identity_upsert(&acct).unwrap();
3914
3915 let identity = Identity {
3916 id: "id-work".to_string(),
3917 name: "Work".to_string(),
3918 description: String::new(),
3919 is_blank: false,
3920 created_at: 0,
3921 updated_at: 0,
3922 };
3923 store.bundle_identity_upsert(&identity).unwrap();
3924
3925 store
3927 .bundle_identity_bind("id-work", "github", "acct-1")
3928 .unwrap();
3929 let bindings = store.bundle_identity_bindings("id-work").unwrap();
3930 assert_eq!(bindings.len(), 1);
3931 assert_eq!(bindings[0].provider, "github");
3932 assert_eq!(bindings[0].account_id, "acct-1");
3933
3934 let acct2 = IdentityAccount {
3936 id: "acct-2".to_string(),
3937 name: "asaf-github-2".to_string(),
3938 provider: "github".to_string(),
3939 kind: "pat".to_string(),
3940 display_name: String::new(),
3941 secret_ref: SecretRef::Env {
3942 env_var: "GITHUB_TOKEN_ALT".to_string(),
3943 },
3944 context: serde_json::json!({}),
3945 status: "unknown".to_string(),
3946 created_at: 0,
3947 updated_at: 0,
3948 };
3949 store.identity_upsert(&acct2).unwrap();
3950 store
3951 .bundle_identity_bind("id-work", "github", "acct-2")
3952 .unwrap();
3953 let rebound = store.bundle_identity_bindings("id-work").unwrap();
3954 assert_eq!(rebound.len(), 1);
3955 assert_eq!(rebound[0].account_id, "acct-2");
3956
3957 assert!(store.bundle_identity_unbind("id-work", "github").unwrap());
3959 assert_eq!(
3960 store.bundle_identity_bindings("id-work").unwrap().len(),
3961 0
3962 );
3963 }
3964
3965 #[test]
3966 fn test_bundle_identity_bindings_cascade_on_account_delete() {
3967 let store = make_store();
3968
3969 let acct = IdentityAccount {
3970 id: "acct-1".to_string(),
3971 name: "asaf-github".to_string(),
3972 provider: "github".to_string(),
3973 kind: "pat".to_string(),
3974 display_name: String::new(),
3975 secret_ref: SecretRef::Env {
3976 env_var: "GITHUB_TOKEN".to_string(),
3977 },
3978 context: serde_json::json!({}),
3979 status: "unknown".to_string(),
3980 created_at: 0,
3981 updated_at: 0,
3982 };
3983 store.identity_upsert(&acct).unwrap();
3984
3985 let identity = Identity {
3986 id: "id-work".to_string(),
3987 name: "Work".to_string(),
3988 description: String::new(),
3989 is_blank: false,
3990 created_at: 0,
3991 updated_at: 0,
3992 };
3993 store.bundle_identity_upsert(&identity).unwrap();
3994 store
3995 .bundle_identity_bind("id-work", "github", "acct-1")
3996 .unwrap();
3997
3998 store.identity_delete("acct-1").unwrap();
4000 assert_eq!(
4001 store.bundle_identity_bindings("id-work").unwrap().len(),
4002 0
4003 );
4004 }
4005
4006 #[test]
4009 fn test_bundle_memory_lifecycle() {
4010 let store = make_store();
4011
4012 let initial = store.bundle_memory_list().unwrap();
4014 assert_eq!(initial.len(), 1);
4015 assert!(initial[0].is_blank);
4016 assert_eq!(initial[0].id, "blank");
4017
4018 let coder = Memory {
4020 id: "mem-coder".to_string(),
4021 name: "Claude-coder".to_string(),
4022 description: "Pair-programming setup".to_string(),
4023 is_blank: false,
4024 provider: "claude".to_string(),
4025 model: "claude-sonnet-4-6".to_string(),
4026 instructions: "You are a careful refactorer.".to_string(),
4027 context_files: "[]".to_string(),
4028 mcp_servers: "[]".to_string(),
4029 skills: "[]".to_string(),
4030 created_at: 100,
4031 updated_at: 100,
4032 };
4033 store.bundle_memory_upsert(&coder).unwrap();
4034
4035 let listed = store.bundle_memory_list().unwrap();
4036 assert_eq!(listed.len(), 2);
4037 assert_eq!(listed[0].id, "mem-coder");
4038 assert_eq!(listed[1].id, "blank");
4039
4040 let fetched = store.bundle_memory_get("mem-coder").unwrap().unwrap();
4041 assert_eq!(fetched.provider, "claude");
4042 assert_eq!(fetched.instructions, "You are a careful refactorer.");
4043
4044 assert!(store.bundle_memory_delete("blank").is_err());
4046
4047 assert!(store.bundle_memory_delete("mem-coder").unwrap());
4049 assert_eq!(store.bundle_memory_list().unwrap().len(), 1);
4050 }
4051
4052 fn make_named_inst(id: &str, name: &str, agents_root: &Path) -> AgentInstance {
4055 let wd = agents_root.join(format!("{name}-fixture")).to_string_lossy().to_string();
4058 AgentInstance {
4059 id: id.to_string(),
4060 definition_id: "def-mirror".to_string(),
4061 parent_instance_id: String::new(),
4062 block_id: String::new(),
4063 session_id: String::new(),
4064 status: "running".to_string(),
4065 github_context: String::new(),
4066 started_at: 1_000,
4067 ended_at: 0,
4068 created_at: 900,
4069 identity_id: String::new(),
4070 memory_id: String::new(),
4071 instance_name: name.to_string(),
4072 working_directory: wd,
4073 display_hidden: false,
4074 }
4075 }
4076
4077 fn store_with_registry() -> (tempfile::TempDir, WaveStore, Arc<crate::registry::Registry>) {
4078 let tmp = tempfile::tempdir().unwrap();
4079 let agents_root = tmp.path().join("agents");
4080 std::fs::create_dir_all(&agents_root).unwrap();
4081 let reg_root = agents_root.join("registry");
4082 let reg = Arc::new(crate::registry::Registry::open(reg_root).unwrap());
4083 let store = WaveStore::open_in_memory().unwrap();
4084 store.set_registry(reg.clone());
4085 let mut agent = sample_agent("def-mirror", "mirror");
4087 store.agent_def_insert(&mut agent).unwrap();
4088 (tmp, store, reg)
4089 }
4090
4091 #[test]
4092 fn instance_create_named_writes_registry_file() {
4093 let (tmp, store, reg) = store_with_registry();
4094 let agents_root = tmp.path().join("agents");
4095 let inst = make_named_inst("inst-1", "demo", &agents_root);
4096 store.instance_create(&inst).unwrap();
4097 let records = reg.list_active().unwrap();
4098 assert_eq!(records.len(), 1);
4099 assert_eq!(records[0].data.instance_id, "inst-1");
4100 assert_eq!(records[0].data.instance_name, "demo");
4101 assert_eq!(records[0].data.identity_id, None);
4102 assert_eq!(records[0].data.memory_id, None);
4103 assert_eq!(records[0].data.working_dir, "demo-fixture");
4104 }
4105
4106 #[test]
4107 fn instance_create_unnamed_does_not_mirror() {
4108 let (tmp, store, reg) = store_with_registry();
4109 let agents_root = tmp.path().join("agents");
4110 let mut inst = make_named_inst("inst-2", "demo2", &agents_root);
4111 inst.instance_name = String::new(); store.instance_create(&inst).unwrap();
4113 assert!(reg.list_active().unwrap().is_empty());
4114 }
4115
4116 #[test]
4117 fn instance_set_hidden_retires_then_unretires() {
4118 let (tmp, store, reg) = store_with_registry();
4119 let agents_root = tmp.path().join("agents");
4120 let inst = make_named_inst("inst-3", "demo3", &agents_root);
4121 store.instance_create(&inst).unwrap();
4122 store.instance_set_hidden("inst-3", true).unwrap();
4123 assert!(reg.list_active().unwrap().is_empty());
4124 store.instance_set_hidden("inst-3", false).unwrap();
4125 assert_eq!(reg.list_active().unwrap().len(), 1);
4126 }
4127
4128 #[test]
4129 fn instance_update_refreshes_registry_record() {
4130 let (tmp, store, reg) = store_with_registry();
4131 let agents_root = tmp.path().join("agents");
4132 let inst = make_named_inst("inst-4", "demo4", &agents_root);
4133 store.instance_create(&inst).unwrap();
4134 let mut updated = inst.clone();
4135 updated.status = "paused".to_string();
4136 updated.session_id = "sess-xyz".to_string();
4137 store.instance_update(&updated).unwrap();
4138 let records = reg.list_active().unwrap();
4142 assert_eq!(records.len(), 1);
4143 assert_eq!(records[0].data.instance_id, "inst-4");
4144 }
4145
4146 #[test]
4147 fn instance_delete_removes_registry_file() {
4148 let (tmp, store, reg) = store_with_registry();
4149 let agents_root = tmp.path().join("agents");
4150 let inst = make_named_inst("inst-5", "demo5", &agents_root);
4151 store.instance_create(&inst).unwrap();
4152 store.instance_delete("inst-5").unwrap();
4153 assert!(reg.list_active().unwrap().is_empty());
4154 }
4155
4156 #[test]
4157 fn instance_create_outside_agents_dir_skips_mirror() {
4158 let (tmp, store, reg) = store_with_registry();
4159 let mut inst = make_named_inst("inst-6", "demo6", tmp.path());
4160 inst.working_directory = tmp.path().join("projects").join("myrepo").to_string_lossy().to_string();
4162 store.instance_create(&inst).unwrap();
4163 assert!(reg.list_active().unwrap().is_empty());
4166 }
4167
4168 #[test]
4169 fn instance_create_user_path_with_agents_segment_is_skipped() {
4170 let (tmp, store, reg) = store_with_registry();
4176 let outside = tmp.path().join("code").join("agents").join("myproject");
4179 let mut inst = make_named_inst("inst-pathconfuse", "confuse", tmp.path());
4180 inst.working_directory = outside.to_string_lossy().to_string();
4181 store.instance_create(&inst).unwrap();
4182 assert!(reg.list_active().unwrap().is_empty(),
4183 "user path with inner 'agents' segment must not be mirrored");
4184 }
4185
4186 #[test]
4187 fn instance_create_continuation_row_does_not_mirror() {
4188 let (tmp, store, reg) = store_with_registry();
4197 let agents_root = tmp.path().join("agents");
4198 let parent = make_named_inst("inst-parent", "demoP", &agents_root);
4199 store.instance_create(&parent).unwrap();
4200 assert_eq!(reg.list_active().unwrap().len(), 1);
4201
4202 let mut child = make_named_inst("inst-child", "demoP", &agents_root);
4203 child.parent_instance_id = "inst-parent".to_string();
4204 store.instance_create(&child).unwrap();
4205 let recs = reg.list_active().unwrap();
4207 assert_eq!(recs.len(), 1);
4208 assert_eq!(recs[0].data.instance_id, "inst-parent");
4209 }
4210
4211 #[test]
4212 fn instance_list_named_picker_mode_includes_continuations() {
4213 let (tmp, store, _reg) = store_with_registry();
4225 let agents_root = tmp.path().join("agents");
4226
4227 let mut head = make_named_inst("inst-head", "Maks", &agents_root);
4228 head.started_at = 100;
4229 store.instance_create(&head).unwrap();
4230
4231 let mut cont = make_named_inst("inst-cont", "Maks", &agents_root);
4232 cont.parent_instance_id = "inst-head".to_string();
4233 cont.started_at = 200;
4234 store.instance_create(&cont).unwrap();
4235
4236 let picker_rows = store.instance_list_named(10, None, true).unwrap();
4238 assert_eq!(picker_rows.len(), 2);
4239 assert_eq!(picker_rows[0].id, "inst-cont");
4240 assert_eq!(picker_rows[1].id, "inst-head");
4241 assert_eq!(picker_rows[0].parent_instance_id, "inst-head");
4244 assert_eq!(picker_rows[1].parent_instance_id, "");
4245
4246 let scoped_picker = store
4248 .instance_list_named(10, Some("def-mirror"), true)
4249 .unwrap();
4250 assert_eq!(scoped_picker.len(), 2);
4251 assert_eq!(scoped_picker[0].id, "inst-cont");
4252 }
4253
4254 #[test]
4255 fn instance_list_named_dropdown_mode_excludes_continuations() {
4256 let (tmp, store, _reg) = store_with_registry();
4265 let agents_root = tmp.path().join("agents");
4266
4267 let mut head = make_named_inst("inst-head", "Maks", &agents_root);
4268 head.started_at = 100;
4269 store.instance_create(&head).unwrap();
4270 let mut cont = make_named_inst("inst-cont", "Maks", &agents_root);
4271 cont.parent_instance_id = "inst-head".to_string();
4272 cont.started_at = 200;
4273 store.instance_create(&cont).unwrap();
4274
4275 let dropdown_rows = store.instance_list_named(10, None, false).unwrap();
4276 assert_eq!(
4277 dropdown_rows.len(),
4278 1,
4279 "legacy dropdown mode must drop continuation rows"
4280 );
4281 assert_eq!(dropdown_rows[0].id, "inst-head");
4282
4283 let scoped_dropdown = store
4285 .instance_list_named(10, Some("def-mirror"), false)
4286 .unwrap();
4287 assert_eq!(scoped_dropdown.len(), 1);
4288 assert_eq!(scoped_dropdown[0].id, "inst-head");
4289 }
4290
4291 #[test]
4292 fn instance_update_does_not_resurrect_hidden_row() {
4293 let (tmp, store, reg) = store_with_registry();
4296 let agents_root = tmp.path().join("agents");
4297 let inst = make_named_inst("inst-resurrect", "demoR", &agents_root);
4298 store.instance_create(&inst).unwrap();
4299 assert_eq!(reg.list_active().unwrap().len(), 1);
4300
4301 store.instance_set_hidden("inst-resurrect", true).unwrap();
4302 assert!(reg.list_active().unwrap().is_empty(),
4303 "after set_hidden(true), record must be in retired/");
4304 assert!(reg.root().join("retired").join("inst-resurrect.json").exists());
4305
4306 let mut updated = inst.clone();
4309 updated.status = "stopped".to_string();
4310 updated.ended_at = 9999;
4311 store.instance_update(&updated).unwrap();
4312
4313 assert!(reg.list_active().unwrap().is_empty(),
4314 "instance_update on a hidden row must NOT resurrect it");
4315 assert!(reg.root().join("retired").join("inst-resurrect.json").exists());
4316 }
4317
4318 #[test]
4319 fn instance_create_with_display_hidden_writes_retired() {
4320 let (tmp, store, reg) = store_with_registry();
4321 let agents_root = tmp.path().join("agents");
4322 let mut inst = make_named_inst("inst-bornhidden", "demoH", &agents_root);
4323 inst.display_hidden = true;
4324 store.instance_create(&inst).unwrap();
4325 assert!(reg.list_active().unwrap().is_empty());
4326 assert!(reg.root().join("retired").join("inst-bornhidden.json").exists());
4327 }
4328
4329 #[test]
4330 fn instance_update_toggling_hidden_off_unretires() {
4331 let (tmp, store, reg) = store_with_registry();
4334 let agents_root = tmp.path().join("agents");
4335 let inst = make_named_inst("inst-toggle", "demoT", &agents_root);
4336 store.instance_create(&inst).unwrap();
4337 store.instance_set_hidden("inst-toggle", true).unwrap();
4338 store.instance_set_hidden("inst-toggle", false).unwrap();
4339 assert_eq!(reg.list_active().unwrap().len(), 1);
4340
4341 let mut updated = inst.clone();
4343 updated.status = "paused".to_string();
4344 store.instance_update(&updated).unwrap();
4345 assert_eq!(reg.list_active().unwrap().len(), 1);
4346 assert!(!reg.root().join("retired").join("inst-toggle.json").exists(),
4347 "no orphan retired file alongside active");
4348 }
4349
4350 #[test]
4351 fn instance_set_hidden_acts_on_registry_only_row() {
4352 let (tmp, store, reg) = store_with_registry();
4357 let agents_root = tmp.path().join("agents");
4359 std::fs::create_dir_all(&agents_root).unwrap();
4360 let wd = agents_root.join("cross-ver");
4361 std::fs::create_dir_all(&wd).unwrap();
4362 reg.upsert(&crate::registry::NamedAgentRecord {
4363 schema_version: crate::registry::MAX_SUPPORTED_SCHEMA,
4364 data: crate::registry::NamedAgentRecordV1 {
4365 instance_id: "inst-crossver".to_string(),
4366 instance_name: "crossver".to_string(),
4367 definition_id: "claude-code".to_string(),
4368 identity_id: None,
4369 memory_id: None,
4370 working_dir: "cross-ver".to_string(),
4371 created_at_ms: 100,
4372 last_launched_at_ms: 100,
4373 created_by_version: "0.33.821".to_string(),
4374 last_launched_by_version: "0.33.821".to_string(),
4375 },
4376 })
4377 .unwrap();
4378 assert_eq!(reg.list_active().unwrap().len(), 1);
4379 assert!(store.instance_get("inst-crossver").unwrap().is_none(),
4380 "precondition: no SQLite row for cross-version agent");
4381
4382 let result = store.instance_set_hidden("inst-crossver", true).unwrap();
4383 assert!(result, "must report success even when only registry was affected");
4384 assert!(reg.list_active().unwrap().is_empty(),
4385 "registry record must be retired");
4386 assert!(reg.root().join("retired").join("inst-crossver.json").exists());
4387 }
4388
4389 #[test]
4390 fn agent_def_delete_cascade_removes_registry_files() {
4391 let (tmp, store, reg) = store_with_registry();
4392 let agents_root = tmp.path().join("agents");
4393 let inst_a = make_named_inst("inst-cascade-a", "demoA", &agents_root);
4394 let inst_b = make_named_inst("inst-cascade-b", "demoB", &agents_root);
4395 store.instance_create(&inst_a).unwrap();
4396 store.instance_create(&inst_b).unwrap();
4397 assert_eq!(reg.list_active().unwrap().len(), 2);
4398
4399 store.agent_def_delete("def-mirror").unwrap();
4402 assert!(reg.list_active().unwrap().is_empty(),
4403 "agent_def_delete cascade must remove all child instance registry files");
4404 }
4405
4406 fn count_agents(store: &WaveStore, where_clause: &str) -> i64 {
4411 let conn = store.conn.lock().unwrap();
4412 let sql = format!("SELECT COUNT(*) FROM db_agents WHERE {where_clause}");
4413 conn.query_row(&sql, [], |row| row.get(0)).unwrap()
4414 }
4415
4416 fn read_agent_field(store: &WaveStore, id: &str, field: &str) -> Option<String> {
4417 let conn = store.conn.lock().unwrap();
4418 let sql = format!("SELECT {field} FROM db_agents WHERE id = ?1");
4419 let mut stmt = conn.prepare(&sql).unwrap();
4420 let r = stmt.query_row(params![id], |row| row.get::<_, String>(0));
4421 match r {
4422 Ok(s) => Some(s),
4423 Err(rusqlite::Error::QueryReturnedNoRows) => None,
4424 Err(e) => panic!("query failed: {e}"),
4425 }
4426 }
4427
4428 fn read_agent_int(store: &WaveStore, id: &str, field: &str) -> Option<i64> {
4429 let conn = store.conn.lock().unwrap();
4430 let sql = format!("SELECT {field} FROM db_agents WHERE id = ?1");
4431 let mut stmt = conn.prepare(&sql).unwrap();
4432 let r = stmt.query_row(params![id], |row| row.get::<_, i64>(0));
4433 match r {
4434 Ok(v) => Some(v),
4435 Err(rusqlite::Error::QueryReturnedNoRows) => None,
4436 Err(e) => panic!("query failed: {e}"),
4437 }
4438 }
4439
4440 #[test]
4441 fn dual_write_agent_def_insert_seeded_creates_template_row() {
4442 let store = make_store();
4443 let mut def = AgentDefinition {
4444 id: "tpl-dw-seeded".to_string(),
4445 slug: String::new(),
4446 name: "Coder".to_string(),
4447 icon: "✦".to_string(),
4448 provider: "claude".to_string(),
4449 description: "desc".to_string(),
4450 working_directory: String::new(),
4451 shell: "bash".to_string(),
4452 provider_flags: String::new(),
4453 auto_start: 0,
4454 restart_on_crash: 0,
4455 idle_timeout_minutes: 0,
4456 created_at: 1000,
4457 agent_type: "standalone".to_string(),
4458 environment: String::new(),
4459 agent_bus_id: String::new(),
4460 is_seeded: 1,
4461 accounts: String::new(),
4462 parent_id: String::new(),
4463 branch_label: String::new(),
4464 updated_at: 1000,
4465 user_hidden: 0,
4466 };
4467 store.agent_def_insert(&mut def).unwrap();
4468 assert_eq!(read_agent_int(&store, "tpl-dw-seeded", "is_template"), Some(1));
4470 assert_eq!(read_agent_field(&store, "tpl-dw-seeded", "parent_template_id"), Some(String::new()));
4471 assert_eq!(read_agent_field(&store, "tpl-dw-seeded", "name"), Some("Coder".to_string()));
4472 assert_eq!(read_agent_field(&store, "tpl-dw-seeded", "provider"), Some("claude".to_string()));
4473 }
4474
4475 #[test]
4476 fn dual_write_agent_def_insert_user_clone_carries_parent() {
4477 let store = make_store();
4478 let mut tpl = AgentDefinition {
4480 id: "tpl-parent".to_string(),
4481 slug: String::new(),
4482 name: "Coder".to_string(),
4483 icon: "✦".to_string(),
4484 provider: "claude".to_string(),
4485 description: String::new(),
4486 working_directory: String::new(),
4487 shell: "bash".to_string(),
4488 provider_flags: String::new(),
4489 auto_start: 0,
4490 restart_on_crash: 0,
4491 idle_timeout_minutes: 0,
4492 created_at: 1000,
4493 agent_type: "standalone".to_string(),
4494 environment: String::new(),
4495 agent_bus_id: String::new(),
4496 is_seeded: 1,
4497 accounts: String::new(),
4498 parent_id: String::new(),
4499 branch_label: String::new(),
4500 updated_at: 1000,
4501 user_hidden: 0,
4502 };
4503 store.agent_def_insert(&mut tpl).unwrap();
4504 let mut user_def = tpl.clone();
4506 user_def.id = "def-user".to_string();
4507 user_def.slug = String::new();
4508 user_def.is_seeded = 0;
4509 user_def.parent_id = "tpl-parent".to_string();
4510 store.agent_def_insert(&mut user_def).unwrap();
4511 assert_eq!(read_agent_int(&store, "def-user", "is_template"), Some(0));
4512 assert_eq!(
4513 read_agent_field(&store, "def-user", "parent_template_id"),
4514 Some("tpl-parent".to_string())
4515 );
4516 }
4517
4518 #[test]
4519 fn dual_write_agent_def_update_refreshes_name_in_db_agents() {
4520 let store = make_store();
4521 let mut def = AgentDefinition {
4522 id: "tpl-update".to_string(),
4523 slug: String::new(),
4524 name: "Old Name".to_string(),
4525 icon: "✦".to_string(),
4526 provider: "claude".to_string(),
4527 description: String::new(),
4528 working_directory: String::new(),
4529 shell: "bash".to_string(),
4530 provider_flags: String::new(),
4531 auto_start: 0,
4532 restart_on_crash: 0,
4533 idle_timeout_minutes: 0,
4534 created_at: 1000,
4535 agent_type: "standalone".to_string(),
4536 environment: String::new(),
4537 agent_bus_id: String::new(),
4538 is_seeded: 1,
4539 accounts: String::new(),
4540 parent_id: String::new(),
4541 branch_label: String::new(),
4542 updated_at: 1000,
4543 user_hidden: 0,
4544 };
4545 store.agent_def_insert(&mut def).unwrap();
4546 def.name = "New Name".to_string();
4547 assert!(store.agent_def_update(&mut def).unwrap());
4548 assert_eq!(
4549 read_agent_field(&store, "tpl-update", "name"),
4550 Some("New Name".to_string())
4551 );
4552 }
4553
4554 #[test]
4555 fn dual_write_agent_def_delete_removes_db_agents_row() {
4556 let store = make_store();
4557 let mut def = AgentDefinition {
4558 id: "tpl-del".to_string(),
4559 slug: String::new(),
4560 name: "Goner".to_string(),
4561 icon: "✦".to_string(),
4562 provider: "claude".to_string(),
4563 description: String::new(),
4564 working_directory: String::new(),
4565 shell: "bash".to_string(),
4566 provider_flags: String::new(),
4567 auto_start: 0,
4568 restart_on_crash: 0,
4569 idle_timeout_minutes: 0,
4570 created_at: 1000,
4571 agent_type: "standalone".to_string(),
4572 environment: String::new(),
4573 agent_bus_id: String::new(),
4574 is_seeded: 1,
4575 accounts: String::new(),
4576 parent_id: String::new(),
4577 branch_label: String::new(),
4578 updated_at: 1000,
4579 user_hidden: 0,
4580 };
4581 store.agent_def_insert(&mut def).unwrap();
4582 assert_eq!(count_agents(&store, "id = 'tpl-del'"), 1);
4583 store.agent_def_delete("tpl-del").unwrap();
4584 assert_eq!(count_agents(&store, "id = 'tpl-del'"), 0);
4585 }
4586
4587 #[test]
4588 fn dual_write_instance_create_inserts_user_clone_row() {
4589 let store = make_store();
4590 let mut tpl = AgentDefinition {
4592 id: "tpl-for-inst".to_string(),
4593 slug: String::new(),
4594 name: "Coder".to_string(),
4595 icon: "✦".to_string(),
4596 provider: "claude".to_string(),
4597 description: "desc".to_string(),
4598 working_directory: String::new(),
4599 shell: "bash".to_string(),
4600 provider_flags: String::new(),
4601 auto_start: 0,
4602 restart_on_crash: 0,
4603 idle_timeout_minutes: 0,
4604 created_at: 1000,
4605 agent_type: "standalone".to_string(),
4606 environment: String::new(),
4607 agent_bus_id: String::new(),
4608 is_seeded: 1,
4609 accounts: String::new(),
4610 parent_id: String::new(),
4611 branch_label: String::new(),
4612 updated_at: 1000,
4613 user_hidden: 0,
4614 };
4615 store.agent_def_insert(&mut tpl).unwrap();
4616
4617 let inst = AgentInstance {
4618 id: "inst-dw".to_string(),
4619 definition_id: "tpl-for-inst".to_string(),
4620 parent_instance_id: String::new(),
4621 block_id: String::new(),
4622 session_id: String::new(),
4623 status: "running".to_string(),
4624 github_context: String::new(),
4625 started_at: 2000,
4626 ended_at: 0,
4627 created_at: 2000,
4628 identity_id: "id-1".to_string(),
4629 memory_id: "mem-1".to_string(),
4630 instance_name: "Maks".to_string(),
4631 working_directory: "/wd/maks".to_string(),
4632 display_hidden: false,
4633 };
4634 store.instance_create(&inst).unwrap();
4635 assert_eq!(read_agent_int(&store, "inst-dw", "is_template"), Some(0));
4638 assert_eq!(
4639 read_agent_field(&store, "inst-dw", "parent_template_id"),
4640 Some("tpl-for-inst".to_string())
4641 );
4642 assert_eq!(read_agent_field(&store, "inst-dw", "name"), Some("Maks".to_string()));
4643 assert_eq!(read_agent_field(&store, "inst-dw", "identity_id"), Some("id-1".to_string()));
4644 assert_eq!(read_agent_field(&store, "inst-dw", "memory_id"), Some("mem-1".to_string()));
4645 assert_eq!(read_agent_field(&store, "inst-dw", "working_directory"), Some("/wd/maks".to_string()));
4646 let cont = AgentInstance {
4648 id: "inst-cont".to_string(),
4649 parent_instance_id: "inst-dw".to_string(),
4650 ..inst.clone()
4651 };
4652 store.instance_create(&cont).unwrap();
4653 assert_eq!(count_agents(&store, "id = 'inst-cont'"), 0);
4654 }
4655
4656 #[test]
4663 fn dual_write_instance_create_folds_into_user_clone_def() {
4664 let store = make_store();
4665 let mut tpl = AgentDefinition {
4667 id: "tpl-folded".to_string(),
4668 slug: String::new(),
4669 name: "Coder".to_string(),
4670 icon: "✦".to_string(),
4671 provider: "claude".to_string(),
4672 description: "desc".to_string(),
4673 working_directory: String::new(),
4674 shell: "bash".to_string(),
4675 provider_flags: String::new(),
4676 auto_start: 0,
4677 restart_on_crash: 0,
4678 idle_timeout_minutes: 0,
4679 created_at: 1000,
4680 agent_type: "standalone".to_string(),
4681 environment: String::new(),
4682 agent_bus_id: String::new(),
4683 is_seeded: 1,
4684 accounts: String::new(),
4685 parent_id: String::new(),
4686 branch_label: String::new(),
4687 updated_at: 1000,
4688 user_hidden: 0,
4689 };
4690 store.agent_def_insert(&mut tpl).unwrap();
4691
4692 let mut clone = AgentDefinition {
4694 id: "user-clone-1".to_string(),
4695 slug: String::new(),
4696 name: "Maks".to_string(),
4697 is_seeded: 0,
4698 parent_id: "tpl-folded".to_string(),
4699 created_at: 1500,
4700 updated_at: 1500,
4701 ..tpl.clone()
4702 };
4703 store.agent_def_insert(&mut clone).unwrap();
4704 assert_eq!(read_agent_field(&store, "user-clone-1", "identity_id"), Some(String::new()));
4706 assert_eq!(read_agent_field(&store, "user-clone-1", "memory_id"), Some(String::new()));
4707
4708 let inst = AgentInstance {
4713 id: "inst-fold-1".to_string(),
4714 definition_id: "user-clone-1".to_string(),
4715 parent_instance_id: String::new(),
4716 block_id: String::new(),
4717 session_id: String::new(),
4718 status: "running".to_string(),
4719 github_context: "gh-ctx-A".to_string(),
4720 started_at: 2000,
4721 ended_at: 0,
4722 created_at: 2000,
4723 identity_id: "id-folded".to_string(),
4724 memory_id: "mem-folded".to_string(),
4725 instance_name: "Maks v2".to_string(),
4726 working_directory: "/wd/folded".to_string(),
4727 display_hidden: false,
4728 };
4729 store.instance_create(&inst).unwrap();
4730
4731 assert_eq!(count_agents(&store, "id = 'inst-fold-1'"), 0);
4734
4735 assert_eq!(read_agent_field(&store, "user-clone-1", "identity_id"), Some("id-folded".to_string()));
4737 assert_eq!(read_agent_field(&store, "user-clone-1", "memory_id"), Some("mem-folded".to_string()));
4738 assert_eq!(read_agent_field(&store, "user-clone-1", "working_directory"), Some("/wd/folded".to_string()));
4739 assert_eq!(read_agent_field(&store, "user-clone-1", "github_context"), Some("gh-ctx-A".to_string()));
4740 assert_eq!(read_agent_field(&store, "user-clone-1", "instance_name"), Some("Maks v2".to_string()));
4741 assert_eq!(read_agent_field(&store, "user-clone-1", "name"), Some("Maks v2".to_string()));
4742 assert_eq!(read_agent_int(&store, "user-clone-1", "is_template"), Some(0));
4745 }
4746
4747 #[test]
4748 fn dual_write_instance_set_hidden_flips_user_hidden_bit() {
4749 let store = make_store();
4750 let mut tpl = AgentDefinition {
4751 id: "tpl-hide".to_string(),
4752 slug: String::new(),
4753 name: "Coder".to_string(),
4754 icon: "✦".to_string(),
4755 provider: "claude".to_string(),
4756 description: String::new(),
4757 working_directory: String::new(),
4758 shell: "bash".to_string(),
4759 provider_flags: String::new(),
4760 auto_start: 0,
4761 restart_on_crash: 0,
4762 idle_timeout_minutes: 0,
4763 created_at: 1000,
4764 agent_type: "standalone".to_string(),
4765 environment: String::new(),
4766 agent_bus_id: String::new(),
4767 is_seeded: 1,
4768 accounts: String::new(),
4769 parent_id: String::new(),
4770 branch_label: String::new(),
4771 updated_at: 1000,
4772 user_hidden: 0,
4773 };
4774 store.agent_def_insert(&mut tpl).unwrap();
4775 let inst = AgentInstance {
4776 id: "inst-hide".to_string(),
4777 definition_id: "tpl-hide".to_string(),
4778 parent_instance_id: String::new(),
4779 block_id: String::new(),
4780 session_id: String::new(),
4781 status: "running".to_string(),
4782 github_context: String::new(),
4783 started_at: 2000,
4784 ended_at: 0,
4785 created_at: 2000,
4786 identity_id: String::new(),
4787 memory_id: String::new(),
4788 instance_name: "H".to_string(),
4789 working_directory: "/wd/h".to_string(),
4790 display_hidden: false,
4791 };
4792 store.instance_create(&inst).unwrap();
4793 assert_eq!(read_agent_int(&store, "inst-hide", "user_hidden"), Some(0));
4794 store.instance_set_hidden("inst-hide", true).unwrap();
4795 assert_eq!(read_agent_int(&store, "inst-hide", "user_hidden"), Some(1));
4796 store.instance_set_hidden("inst-hide", false).unwrap();
4797 assert_eq!(read_agent_int(&store, "inst-hide", "user_hidden"), Some(0));
4798 }
4799
4800 #[test]
4801 fn dual_write_instance_delete_drops_db_agents_row() {
4802 let store = make_store();
4803 let mut tpl = AgentDefinition {
4804 id: "tpl-instdel".to_string(),
4805 slug: String::new(),
4806 name: "Coder".to_string(),
4807 icon: "✦".to_string(),
4808 provider: "claude".to_string(),
4809 description: String::new(),
4810 working_directory: String::new(),
4811 shell: "bash".to_string(),
4812 provider_flags: String::new(),
4813 auto_start: 0,
4814 restart_on_crash: 0,
4815 idle_timeout_minutes: 0,
4816 created_at: 1000,
4817 agent_type: "standalone".to_string(),
4818 environment: String::new(),
4819 agent_bus_id: String::new(),
4820 is_seeded: 1,
4821 accounts: String::new(),
4822 parent_id: String::new(),
4823 branch_label: String::new(),
4824 updated_at: 1000,
4825 user_hidden: 0,
4826 };
4827 store.agent_def_insert(&mut tpl).unwrap();
4828 let inst = AgentInstance {
4829 id: "inst-del".to_string(),
4830 definition_id: "tpl-instdel".to_string(),
4831 parent_instance_id: String::new(),
4832 block_id: String::new(),
4833 session_id: String::new(),
4834 status: "running".to_string(),
4835 github_context: String::new(),
4836 started_at: 2000,
4837 ended_at: 0,
4838 created_at: 2000,
4839 identity_id: String::new(),
4840 memory_id: String::new(),
4841 instance_name: "D".to_string(),
4842 working_directory: "/wd/d".to_string(),
4843 display_hidden: false,
4844 };
4845 store.instance_create(&inst).unwrap();
4846 assert_eq!(count_agents(&store, "id = 'inst-del'"), 1);
4847 store.instance_delete("inst-del").unwrap();
4848 assert_eq!(count_agents(&store, "id = 'inst-del'"), 0);
4849 }
4850
4851 #[test]
4852 fn dual_write_instance_repoint_updates_parent_template_id() {
4853 let store = make_store();
4854 let mut tpl_a = AgentDefinition {
4855 id: "tpl-A".to_string(),
4856 slug: String::new(),
4857 name: "A".to_string(),
4858 icon: "✦".to_string(),
4859 provider: "claude".to_string(),
4860 description: String::new(),
4861 working_directory: String::new(),
4862 shell: "bash".to_string(),
4863 provider_flags: String::new(),
4864 auto_start: 0,
4865 restart_on_crash: 0,
4866 idle_timeout_minutes: 0,
4867 created_at: 1000,
4868 agent_type: "standalone".to_string(),
4869 environment: String::new(),
4870 agent_bus_id: String::new(),
4871 is_seeded: 1,
4872 accounts: String::new(),
4873 parent_id: String::new(),
4874 branch_label: String::new(),
4875 updated_at: 1000,
4876 user_hidden: 0,
4877 };
4878 store.agent_def_insert(&mut tpl_a).unwrap();
4879 let mut tpl_b = tpl_a.clone();
4880 tpl_b.id = "tpl-B".to_string();
4881 tpl_b.slug = String::new();
4882 store.agent_def_insert(&mut tpl_b).unwrap();
4883
4884 let inst = AgentInstance {
4885 id: "inst-rp".to_string(),
4886 definition_id: "tpl-A".to_string(),
4887 parent_instance_id: String::new(),
4888 block_id: String::new(),
4889 session_id: String::new(),
4890 status: "running".to_string(),
4891 github_context: String::new(),
4892 started_at: 2000,
4893 ended_at: 0,
4894 created_at: 2000,
4895 identity_id: String::new(),
4896 memory_id: String::new(),
4897 instance_name: "R".to_string(),
4898 working_directory: "/wd/r".to_string(),
4899 display_hidden: false,
4900 };
4901 store.instance_create(&inst).unwrap();
4902 assert_eq!(
4903 read_agent_field(&store, "inst-rp", "parent_template_id"),
4904 Some("tpl-A".to_string())
4905 );
4906 store.instance_repoint_definition("tpl-A", "tpl-B").unwrap();
4907 assert_eq!(
4908 read_agent_field(&store, "inst-rp", "parent_template_id"),
4909 Some("tpl-B".to_string())
4910 );
4911 }
4912
4913 #[test]
4914 fn dual_write_agent_def_delete_seeded_drops_all_template_rows() {
4915 let store = make_store();
4916 for id in &["s1", "s2", "s3"] {
4917 let mut d = AgentDefinition {
4918 id: id.to_string(),
4919 slug: String::new(),
4920 name: id.to_string(),
4921 icon: "✦".to_string(),
4922 provider: "claude".to_string(),
4923 description: String::new(),
4924 working_directory: String::new(),
4925 shell: "bash".to_string(),
4926 provider_flags: String::new(),
4927 auto_start: 0,
4928 restart_on_crash: 0,
4929 idle_timeout_minutes: 0,
4930 created_at: 1000,
4931 agent_type: "standalone".to_string(),
4932 environment: String::new(),
4933 agent_bus_id: String::new(),
4934 is_seeded: 1,
4935 accounts: String::new(),
4936 parent_id: String::new(),
4937 branch_label: String::new(),
4938 updated_at: 1000,
4939 user_hidden: 0,
4940 };
4941 store.agent_def_insert(&mut d).unwrap();
4942 }
4943 assert_eq!(count_agents(&store, "is_template = 1"), 3);
4944 store.agent_def_delete_seeded().unwrap();
4945 assert_eq!(count_agents(&store, "is_template = 1"), 0);
4946 }
4947
4948 #[test]
4952 fn dual_write_seeded_delete_preserves_user_clone_def_projections() {
4953 let store = make_store();
4954 let mut tpl = AgentDefinition {
4956 id: "tpl-keep-check".to_string(),
4957 slug: String::new(),
4958 name: "TplCheck".to_string(),
4959 icon: "✦".to_string(),
4960 provider: "claude".to_string(),
4961 description: String::new(),
4962 working_directory: String::new(),
4963 shell: "bash".to_string(),
4964 provider_flags: String::new(),
4965 auto_start: 0,
4966 restart_on_crash: 0,
4967 idle_timeout_minutes: 0,
4968 created_at: 1000,
4969 agent_type: "standalone".to_string(),
4970 environment: String::new(),
4971 agent_bus_id: String::new(),
4972 is_seeded: 1,
4973 accounts: String::new(),
4974 parent_id: String::new(),
4975 branch_label: String::new(),
4976 updated_at: 1000,
4977 user_hidden: 0,
4978 };
4979 store.agent_def_insert(&mut tpl).unwrap();
4980 let mut clone = AgentDefinition {
4982 id: "user-clone-keep".to_string(),
4983 slug: String::new(),
4984 name: "MaksKeeper".to_string(),
4985 is_seeded: 0,
4986 parent_id: "tpl-keep-check".to_string(),
4987 created_at: 1500,
4988 updated_at: 1500,
4989 ..tpl.clone()
4990 };
4991 store.agent_def_insert(&mut clone).unwrap();
4992 let inst_on_tpl = AgentInstance {
4994 id: "inst-on-tpl-keep".to_string(),
4995 definition_id: "tpl-keep-check".to_string(),
4996 parent_instance_id: String::new(),
4997 block_id: String::new(),
4998 session_id: String::new(),
4999 status: "running".to_string(),
5000 github_context: String::new(),
5001 started_at: 2000,
5002 ended_at: 0,
5003 created_at: 2000,
5004 identity_id: String::new(),
5005 memory_id: String::new(),
5006 instance_name: String::new(),
5007 working_directory: String::new(),
5008 display_hidden: false,
5009 };
5010 store.instance_create(&inst_on_tpl).unwrap();
5011 store.agent_def_delete_seeded().unwrap();
5013 assert_eq!(count_agents(&store, "id = 'tpl-keep-check'"), 0, "template projection gone");
5014 assert_eq!(count_agents(&store, "id = 'inst-on-tpl-keep'"), 0, "cascaded instance projection gone");
5015 assert_eq!(count_agents(&store, "id = 'user-clone-keep'"), 1, "user-clone def projection survives");
5016 }
5017
5018 #[test]
5022 fn dual_write_instance_lifecycle_on_user_clone_def_routes_to_folded_row() {
5023 let store = make_store();
5024 let mut tpl = AgentDefinition {
5026 id: "tpl-rt".to_string(),
5027 slug: String::new(),
5028 name: "Tpl".to_string(),
5029 icon: "✦".to_string(),
5030 provider: "claude".to_string(),
5031 description: String::new(),
5032 working_directory: String::new(),
5033 shell: "bash".to_string(),
5034 provider_flags: String::new(),
5035 auto_start: 0,
5036 restart_on_crash: 0,
5037 idle_timeout_minutes: 0,
5038 created_at: 1000,
5039 agent_type: "standalone".to_string(),
5040 environment: String::new(),
5041 agent_bus_id: String::new(),
5042 is_seeded: 1,
5043 accounts: String::new(),
5044 parent_id: String::new(),
5045 branch_label: String::new(),
5046 updated_at: 1000,
5047 user_hidden: 0,
5048 };
5049 store.agent_def_insert(&mut tpl).unwrap();
5050 let mut clone = AgentDefinition {
5051 id: "user-rt".to_string(),
5052 slug: String::new(),
5053 name: "Maks".to_string(),
5054 is_seeded: 0,
5055 parent_id: "tpl-rt".to_string(),
5056 created_at: 1500,
5057 updated_at: 1500,
5058 ..tpl.clone()
5059 };
5060 store.agent_def_insert(&mut clone).unwrap();
5061 let inst = AgentInstance {
5062 id: "inst-rt".to_string(),
5063 definition_id: "user-rt".to_string(),
5064 parent_instance_id: String::new(),
5065 block_id: String::new(),
5066 session_id: String::new(),
5067 status: "running".to_string(),
5068 github_context: "gh-initial".to_string(),
5069 started_at: 2000,
5070 ended_at: 0,
5071 created_at: 2000,
5072 identity_id: "id-init".to_string(),
5073 memory_id: "mem-init".to_string(),
5074 instance_name: "Maks v1".to_string(),
5075 working_directory: "/wd".to_string(),
5076 display_hidden: false,
5077 };
5078 store.instance_create(&inst).unwrap();
5079 assert_eq!(count_agents(&store, "id = 'inst-rt'"), 0);
5081 assert_eq!(read_agent_field(&store, "user-rt", "github_context"), Some("gh-initial".to_string()));
5082
5083 let updated = AgentInstance {
5085 github_context: "gh-updated".to_string(),
5086 ..inst.clone()
5087 };
5088 store.instance_update(&updated).unwrap();
5089 assert_eq!(
5090 read_agent_field(&store, "user-rt", "github_context"),
5091 Some("gh-updated".to_string()),
5092 "instance_update on user-clone-def routes to folded row",
5093 );
5094
5095 store.instance_set_hidden("inst-rt", true).unwrap();
5097 assert_eq!(
5098 read_agent_int(&store, "user-rt", "user_hidden"),
5099 Some(1),
5100 "instance_set_hidden routes to folded row",
5101 );
5102
5103 store.instance_delete("inst-rt").unwrap();
5105 assert_eq!(
5106 count_agents(&store, "id = 'user-rt'"),
5107 1,
5108 "instance_delete on user-clone-def is a no-op (def projection persists)",
5109 );
5110 }
5111}